From 13ee5513c7fd71bcd7aa9ff33d48cfdb48630793 Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Mon, 9 Sep 2024 15:24:47 +0300 Subject: [PATCH] feat(HIP-904) - TokenAirdrop Claim/Cancel (#1972) Signed-off-by: Ivan Ivanov --- .../sdk/examples/TokenAirdropExample.java | 390 +++++++++++++ scripts/update_protobufs.py | 1 + .../hashgraph/sdk/PendingAirdropLogic.java | 125 +++++ .../java/com/hedera/hashgraph/sdk/Status.java | 41 +- .../sdk/TokenCancelAirdropTransaction.java | 103 ++++ .../sdk/TokenClaimAirdropTransaction.java | 102 ++++ .../com/hedera/hashgraph/sdk/Transaction.java | 4 + sdk/src/main/proto/account.proto | 6 + sdk/src/main/proto/basic_types.proto | 25 +- sdk/src/main/proto/block_stream_info.proto | 88 +++ sdk/src/main/proto/recordcache.proto | 32 ++ sdk/src/main/proto/response_code.proto | 33 +- sdk/src/main/proto/roster.proto | 107 ++++ sdk/src/main/proto/roster_state.proto | 77 +++ sdk/src/main/proto/token_airdrop.proto | 8 +- .../TokenCancelAirdropTransactionTest.java | 170 ++++++ .../TokenCancelAirdropTransactionTest.snap | 3 + .../sdk/TokenClaimAirdropTransactionTest.java | 170 ++++++ .../sdk/TokenClaimAirdropTransactionTest.snap | 3 + .../TokenAirdropCancelIntegrationTest.java | 482 ++++++++++++++++ .../TokenAirdropClaimIntegrationTest.java | 514 ++++++++++++++++++ 21 files changed, 2473 insertions(+), 11 deletions(-) create mode 100644 examples/src/main/java/com/hedera/hashgraph/sdk/examples/TokenAirdropExample.java create mode 100644 sdk/src/main/java/com/hedera/hashgraph/sdk/PendingAirdropLogic.java create mode 100644 sdk/src/main/java/com/hedera/hashgraph/sdk/TokenCancelAirdropTransaction.java create mode 100644 sdk/src/main/java/com/hedera/hashgraph/sdk/TokenClaimAirdropTransaction.java create mode 100644 sdk/src/main/proto/block_stream_info.proto create mode 100644 sdk/src/main/proto/roster.proto create mode 100644 sdk/src/main/proto/roster_state.proto create mode 100644 sdk/src/test/java/com/hedera/hashgraph/sdk/TokenCancelAirdropTransactionTest.java create mode 100644 sdk/src/test/java/com/hedera/hashgraph/sdk/TokenCancelAirdropTransactionTest.snap create mode 100644 sdk/src/test/java/com/hedera/hashgraph/sdk/TokenClaimAirdropTransactionTest.java create mode 100644 sdk/src/test/java/com/hedera/hashgraph/sdk/TokenClaimAirdropTransactionTest.snap create mode 100644 sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/TokenAirdropCancelIntegrationTest.java create mode 100644 sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/TokenAirdropClaimIntegrationTest.java diff --git a/examples/src/main/java/com/hedera/hashgraph/sdk/examples/TokenAirdropExample.java b/examples/src/main/java/com/hedera/hashgraph/sdk/examples/TokenAirdropExample.java new file mode 100644 index 0000000000..69c2be608c --- /dev/null +++ b/examples/src/main/java/com/hedera/hashgraph/sdk/examples/TokenAirdropExample.java @@ -0,0 +1,390 @@ +/*- + * + * Hedera Java SDK + * + * Copyright (C) 2020 - 2024 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.hashgraph.sdk.examples; + +import com.hedera.hashgraph.sdk.AccountBalanceQuery; +import com.hedera.hashgraph.sdk.AccountCreateTransaction; +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.Client; +import com.hedera.hashgraph.sdk.Hbar; +import com.hedera.hashgraph.sdk.PrivateKey; +import com.hedera.hashgraph.sdk.TokenAirdropTransaction; +import com.hedera.hashgraph.sdk.TokenCancelAirdropTransaction; +import com.hedera.hashgraph.sdk.TokenClaimAirdropTransaction; +import com.hedera.hashgraph.sdk.TokenCreateTransaction; +import com.hedera.hashgraph.sdk.TokenMintTransaction; +import com.hedera.hashgraph.sdk.TokenRejectTransaction; +import com.hedera.hashgraph.sdk.TokenSupplyType; +import com.hedera.hashgraph.sdk.TokenType; +import com.hedera.hashgraph.sdk.logger.LogLevel; +import com.hedera.hashgraph.sdk.logger.Logger; +import io.github.cdimascio.dotenv.Dotenv; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public class TokenAirdropExample { + + /* + * See .env.sample in the examples folder root for how to specify values below + * or set environment variables with the same names. + */ + + /** + * Operator's account ID. Used to sign and pay for operations on Hedera. + */ + private static final AccountId OPERATOR_ID = AccountId.fromString( + Objects.requireNonNull(Dotenv.load().get("OPERATOR_ID"))); + + /** + * Operator's private key. + */ + private static final PrivateKey OPERATOR_KEY = PrivateKey.fromString( + Objects.requireNonNull(Dotenv.load().get("OPERATOR_KEY"))); + + /** + * HEDERA_NETWORK defaults to testnet if not specified in dotenv file. Network can be: localhost, testnet, + * previewnet or mainnet. + */ + private static final String HEDERA_NETWORK = Dotenv.load().get("HEDERA_NETWORK", "testnet"); + + /** + * SDK_LOG_LEVEL defaults to SILENT if not specified in dotenv file. Log levels can be: TRACE, DEBUG, INFO, WARN, + * ERROR, SILENT. + *

+ * Important pre-requisite: set simple logger log level to same level as the SDK_LOG_LEVEL, for example via VM + * options: -Dorg.slf4j.simpleLogger.log.com.hedera.hashgraph=trace + */ + private static final String SDK_LOG_LEVEL = Dotenv.load().get("SDK_LOG_LEVEL", "SILENT"); + + public static void main(String[] args) throws Exception { + System.out.println("Example Start!"); + + /* + * Step 0: + * Create and configure SDK Client. + */ + Client client = ClientHelper.forName(HEDERA_NETWORK); + // All generated transactions will be paid by this account and signed by this key. + client.setOperator(OPERATOR_ID, OPERATOR_KEY); + // Attach logger to the SDK Client. + client.setLogger(new Logger(LogLevel.valueOf(SDK_LOG_LEVEL))); + + /* + * Step 1: + * Create 4 accounts + */ + var privateKey1 = PrivateKey.generateECDSA(); + var alice = new AccountCreateTransaction() + .setKey(privateKey1) + .setInitialBalance(new Hbar(10)) + .setMaxAutomaticTokenAssociations(-1) + .execute(client) + .getReceipt(client) + .accountId; + + var privateKey2 = PrivateKey.generateECDSA(); + var bob = new AccountCreateTransaction() + .setKey(privateKey2) + .setMaxAutomaticTokenAssociations(1) + .execute(client) + .getReceipt(client) + .accountId; + + var privateKey3 = PrivateKey.generateECDSA(); + var carol = new AccountCreateTransaction() + .setKey(privateKey3) + .setMaxAutomaticTokenAssociations(0) + .execute(client) + .getReceipt(client) + .accountId; + + var treasuryKey = PrivateKey.generateECDSA(); + var treasuryAccount = new AccountCreateTransaction() + .setKey(treasuryKey) + .setInitialBalance(new Hbar(10)) + .execute(client) + .getReceipt(client) + .accountId; + + /* + * Step 2: + * Create FT and NFT and mint + */ + var tokenID = new TokenCreateTransaction() + .setTokenName("Fungible Token") + .setTokenSymbol("TFT") + .setTokenMemo("Example memo") + .setDecimals(3) + .setInitialSupply(100) + .setMaxSupply(100) + .setTreasuryAccountId(treasuryAccount) + .setSupplyType(TokenSupplyType.FINITE) + .setAdminKey(client.getOperatorPublicKey()) + .setFreezeKey(client.getOperatorPublicKey()) + .setSupplyKey(client.getOperatorPublicKey()) + .setMetadataKey(client.getOperatorPublicKey()) + .setPauseKey(client.getOperatorPublicKey()) + .freezeWith(client) + .sign(treasuryKey) + .execute(client) + .getReceipt(client) + .tokenId; + + var nftID = new TokenCreateTransaction() + .setTokenName("Test NFT") + .setTokenSymbol("TNFT") + .setTokenType(TokenType.NON_FUNGIBLE_UNIQUE) + .setTreasuryAccountId(treasuryAccount) + .setSupplyType(TokenSupplyType.FINITE) + .setMaxSupply(10) + .setSupplyType(TokenSupplyType.FINITE) + .setAdminKey(client.getOperatorPublicKey()) + .setFreezeKey(client.getOperatorPublicKey()) + .setSupplyKey(client.getOperatorPublicKey()) + .setMetadataKey(client.getOperatorPublicKey()) + .setPauseKey(client.getOperatorPublicKey()) + .freezeWith(client) + .sign(treasuryKey) + .execute(client) + .getReceipt(client) + .tokenId; + + new TokenMintTransaction() + .setTokenId(nftID) + .setMetadata(generateNftMetadata((byte) 3)) + .execute(client) + .getReceipt(client); + + + /* + * Step 3: + * Airdrop fungible tokens to all 3 accounts + */ + System.out.println("Airdropping fts"); + var txnRecord = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, alice, 10) + .addTokenTransfer(tokenID, treasuryAccount, -10) + .addTokenTransfer(tokenID, bob, 10) + .addTokenTransfer(tokenID, treasuryAccount, -10) + .addTokenTransfer(tokenID, carol, 10) + .addTokenTransfer(tokenID, treasuryAccount, -10) + .freezeWith(client) + .sign(treasuryKey) + .execute(client) + .getRecord(client); + + /* + * Step 4: + * Get the transaction record and see one pending airdrop (for carol) + */ + System.out.println("Pending airdrops length: " + txnRecord.pendingAirdropRecords.size()); + System.out.println("Pending airdrops: " + txnRecord.pendingAirdropRecords.get(0)); + + /* + * Step 5: + * Query to verify alice and bob received the airdrops and carol did not + */ + var aliceBalance = new AccountBalanceQuery() + .setAccountId(alice) + .execute(client); + var bobBalance = new AccountBalanceQuery() + .setAccountId(bob) + .execute(client); + var carolBalance = new AccountBalanceQuery() + .setAccountId(carol) + .execute(client); + + System.out.println("Alice ft balance after airdrop: " + aliceBalance.tokens.get(tokenID)); + System.out.println("Bob ft balance after airdrop: " + bobBalance.tokens.get(tokenID)); + System.out.println("Carol ft balance after airdrop: " + carolBalance.tokens.get(tokenID)); + + /* + * Step 6: + * Claim the airdrop for carol + */ + System.out.println("Claiming ft with carol"); + new TokenClaimAirdropTransaction() + .addPendingAirdrop(txnRecord.pendingAirdropRecords.get(0).getPendingAirdropId()) + .freezeWith(client) + .sign(privateKey3) + .execute(client) + .getReceipt(client); + + carolBalance = new AccountBalanceQuery() + .setAccountId(carol) + .execute(client); + System.out.println("Carol ft balance after claim: " + carolBalance.tokens.get(tokenID)); + + /* + * Step 7: + * Airdrop the NFTs to all three accounts + */ + System.out.println("Airdropping nfts"); + txnRecord = new TokenAirdropTransaction() + .addNftTransfer(nftID.nft(1), treasuryAccount, alice) + .addNftTransfer(nftID.nft(2), treasuryAccount, bob) + .addNftTransfer(nftID.nft(3), treasuryAccount, carol) + .freezeWith(client) + .sign(treasuryKey) + .execute(client) + .getRecord(client); + + /* + * Step 8: + * Get the transaction record and verify two pending airdrops (for bob & 3) + */ + System.out.println("Pending airdrops length: " + txnRecord.pendingAirdropRecords.size()); + System.out.println("Pending airdrops for Bob: " + txnRecord.pendingAirdropRecords.get(0)); + System.out.println("Pending airdrops for Carol: " + txnRecord.pendingAirdropRecords.get(1)); + + /* + * Step 9: + * Query to verify alice received the airdrop and bob and carol did not + */ + aliceBalance = new AccountBalanceQuery() + .setAccountId(alice) + .execute(client); + bobBalance = new AccountBalanceQuery() + .setAccountId(bob) + .execute(client); + carolBalance = new AccountBalanceQuery() + .setAccountId(carol) + .execute(client); + + System.out.println("Alice nft balance after airdrop: " + aliceBalance.tokens.get(nftID)); + System.out.println("Bob nft balance after airdrop: " + bobBalance.tokens.get(nftID)); + System.out.println("Carol nft balance after airdrop: " + carolBalance.tokens.get(nftID)); + + /* + * Step 10: + * Claim the airdrop for bob + */ + System.out.println("Claiming nft with Bob"); + new TokenClaimAirdropTransaction() + .addPendingAirdrop(txnRecord.pendingAirdropRecords.get(0).getPendingAirdropId()) + .freezeWith(client) + .sign(privateKey2) + .execute(client) + .getReceipt(client); + + bobBalance = new AccountBalanceQuery() + .setAccountId(bob) + .execute(client); + System.out.println("Bob nft balance after claim: " + bobBalance.tokens.get(nftID)); + + /* + * Step 11: + * Cancel the airdrop for carol + */ + System.out.println("Canceling nft for Carol"); + new TokenCancelAirdropTransaction() + .addPendingAirdrop(txnRecord.pendingAirdropRecords.get(1).getPendingAirdropId()) + .freezeWith(client) + .sign(treasuryKey) + .execute(client) + .getReceipt(client); + + carolBalance = new AccountBalanceQuery() + .setAccountId(carol) + .execute(client); + System.out.println("Carol nft balance after cancel: " + carolBalance.tokens.get(nftID)); + + /* + * Step 12: + * Reject the NFT for bob + */ + System.out.println("Rejecting nft with Bob"); + new TokenRejectTransaction() + .setOwnerId(bob) + .addNftId(nftID.nft(2)) + .freezeWith(client) + .sign(privateKey2) + .execute(client) + .getReceipt(client); + + /* + * Step 13: + * Query to verify bob no longer has the NFT + */ + bobBalance = new AccountBalanceQuery() + .setAccountId(bob) + .execute(client); + System.out.println("Bob nft balance after reject: " + bobBalance.tokens.get(nftID)); + + /* + * Step 13: + * Query to verify the NFT was returned to the Treasury + */ + var treasuryBalance = new AccountBalanceQuery() + .setAccountId(treasuryAccount) + .execute(client); + System.out.println("Treasury nft balance after reject: " + treasuryBalance.tokens.get(nftID)); + + /* + * Step 14: + * Reject the fungible tokens for Carol + */ + System.out.println("Rejecting ft with Carol"); + new TokenRejectTransaction() + .setOwnerId(carol) + .addTokenId(tokenID) + .freezeWith(client) + .sign(privateKey3) + .execute(client) + .getReceipt(client); + + /* + * Step 14: + * Query to verify carol no longer has the fungible tokens + */ + carolBalance = new AccountBalanceQuery() + .setAccountId(carol) + .execute(client); + System.out.println("Carol ft balance after reject: " + carolBalance.tokens.get(tokenID)); + + /* + * Step 15: + * Query to verify Treasury received the rejected fungible tokens + */ + treasuryBalance = new AccountBalanceQuery() + .setAccountId(treasuryAccount) + .execute(client); + System.out.println("Treasury ft balance after reject: " + treasuryBalance.tokens.get(tokenID)); + + /* + * Clean up: + */ + client.close(); + + System.out.println("Example Complete!"); + } + + private static List generateNftMetadata(byte metadataCount) { + List metadatas = new ArrayList<>(); + + for (byte i = 0; i < metadataCount; i++) { + byte[] md = {i}; + metadatas.add(md); + } + + return metadatas; + } +} diff --git a/scripts/update_protobufs.py b/scripts/update_protobufs.py index 882388ef51..521ae0c1b1 100755 --- a/scripts/update_protobufs.py +++ b/scripts/update_protobufs.py @@ -135,6 +135,7 @@ def ensure_protobufs(): print(">>> No protobufs detected") run_command("git", "clone", PROTO_GIT_REMOTE, PROTO_GIT_PATH) os.chdir(PROTO_GIT_PATH) + run_command("git", "fetch") checkout_ref = PROTO_GIT_REF if PROTO_GIT_REF else get_latest_tag() print(f">>> Checking out {checkout_ref}") run_command("git", "checkout", checkout_ref) diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/PendingAirdropLogic.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/PendingAirdropLogic.java new file mode 100644 index 0000000000..c8fafb043c --- /dev/null +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/PendingAirdropLogic.java @@ -0,0 +1,125 @@ +/*- + * + * Hedera Java SDK + * + * Copyright (C) 2020 - 2024 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.hashgraph.sdk; + +import com.google.protobuf.InvalidProtocolBufferException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Objects; + +abstract class PendingAirdropLogic> extends Transaction { + + protected List pendingAirdropIds = new ArrayList<>(); + + + protected PendingAirdropLogic() {} + + /** + * Constructor. + * + * @param txs Compound list of transaction id's list of (AccountId, Transaction) records + * @throws InvalidProtocolBufferException when there is an issue with the protobuf + */ + PendingAirdropLogic( + LinkedHashMap> txs) + throws InvalidProtocolBufferException { + super(txs); + } + + /** + * Constructor. + * + * @param txBody protobuf TransactionBody + */ + PendingAirdropLogic(com.hedera.hashgraph.sdk.proto.TransactionBody txBody) { + super(txBody); + } + + /** + * Extract the pending airdrop ids + * + * @return the pending airdrop ids + */ + public List getPendingAirdropIds() { + return this.pendingAirdropIds; + } + + /** + * Set the pending airdrop ids + * + * @param pendingAirdropIds + * @return {@code this} + */ + public T setPendingAirdropIds(List pendingAirdropIds) { + Objects.requireNonNull(pendingAirdropIds); + requireNotFrozen(); + this.pendingAirdropIds = pendingAirdropIds; + // noinspection unchecked + return (T) this; + } + + /** + * clear the pending airdrop ids + * + * @return {@code this} + */ + public T clearPendingAirdropIds() { + requireNotFrozen(); + this.pendingAirdropIds = new ArrayList<>(); + // noinspection unchecked + return (T) this; + } + + /** + * Add pendingAirdropId + * + * @param pendingAirdropId + * @return {@code this} + */ + public T addPendingAirdrop(PendingAirdropId pendingAirdropId) { + Objects.requireNonNull(pendingAirdropId); + requireNotFrozen(); + this.pendingAirdropIds.add(pendingAirdropId); + // noinspection unchecked + return (T) this; + } + + @Override + void validateChecksums(Client client) throws BadEntityIdException { + for (var pendingAirdropId : pendingAirdropIds) { + if (pendingAirdropId.getTokenId() != null) { + pendingAirdropId.getTokenId().validateChecksum(client); + } + + if (pendingAirdropId.getReceiver() != null) { + pendingAirdropId.getReceiver().validateChecksum(client); + } + + if (pendingAirdropId.getSender() != null) { + pendingAirdropId.getSender().validateChecksum(client); + } + + if (pendingAirdropId.getNftId() != null) { + pendingAirdropId.getNftId().tokenId.validateChecksum(client); + } + } + } +} diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/Status.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/Status.java index e455cccc4d..b0fc94041e 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/Status.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/Status.java @@ -1705,7 +1705,7 @@ public enum Status { /** * The transaction attempted to use more than the allowed number of `PendingAirdropId`. */ - MAX_PENDING_AIRDROP_ID_EXCEEDED(ResponseCodeEnum.MAX_PENDING_AIRDROP_ID_EXCEEDED), + PENDING_AIRDROP_ID_LIST_TOO_LONG(ResponseCodeEnum.PENDING_AIRDROP_ID_LIST_TOO_LONG), /** * A pending airdrop already exists for the specified NFT. @@ -1718,7 +1718,38 @@ public enum Status { * Requester should cancel all pending airdrops before resending * this transaction. */ - ACCOUNT_HAS_PENDING_AIRDROPS(ResponseCodeEnum.ACCOUNT_HAS_PENDING_AIRDROPS); + ACCOUNT_HAS_PENDING_AIRDROPS(ResponseCodeEnum.ACCOUNT_HAS_PENDING_AIRDROPS), + + /** + * Consensus throttle did not allow execution of this transaction.
+ * The transaction should be retried after a modest delay. + */ + THROTTLED_AT_CONSENSUS(ResponseCodeEnum.THROTTLED_AT_CONSENSUS), + + /** + * The provided pending airdrop id is invalid.
+ * This pending airdrop MAY already be claimed or cancelled. + *

+ * The client SHOULD query a mirror node to determine the current status of + * the pending airdrop. + */ + INVALID_PENDING_AIRDROP_ID(ResponseCodeEnum.INVALID_PENDING_AIRDROP_ID), + + /** + * The token to be airdropped has a fallback royalty fee and cannot be + * sent or claimed via an airdrop transaction. + */ + TOKEN_AIRDROP_WITH_FALLBACK_ROYALTY(ResponseCodeEnum.TOKEN_AIRDROP_WITH_FALLBACK_ROYALTY), + + /** + * This airdrop claim is for a pending airdrop with an invalid token.
+ * The token might be deleted, or the sender may not have enough tokens + * to fulfill the offer. + *

+ * The client SHOULD query mirror node to determine the status of the pending + * airdrop and whether the sender can fulfill the offer. + */ + INVALID_TOKEN_IN_PENDING_AIRDROP(ResponseCodeEnum.INVALID_TOKEN_IN_PENDING_AIRDROP); final ResponseCodeEnum code; @@ -2049,9 +2080,13 @@ static Status valueOf(ResponseCodeEnum code) { case TOKEN_HAS_NO_METADATA_OR_SUPPLY_KEY -> TOKEN_HAS_NO_METADATA_OR_SUPPLY_KEY; case EMPTY_PENDING_AIRDROP_ID_LIST -> EMPTY_PENDING_AIRDROP_ID_LIST; case PENDING_AIRDROP_ID_REPEATED -> PENDING_AIRDROP_ID_REPEATED; - case MAX_PENDING_AIRDROP_ID_EXCEEDED -> MAX_PENDING_AIRDROP_ID_EXCEEDED; + case PENDING_AIRDROP_ID_LIST_TOO_LONG -> PENDING_AIRDROP_ID_LIST_TOO_LONG; case PENDING_NFT_AIRDROP_ALREADY_EXISTS -> PENDING_NFT_AIRDROP_ALREADY_EXISTS; case ACCOUNT_HAS_PENDING_AIRDROPS -> ACCOUNT_HAS_PENDING_AIRDROPS; + case THROTTLED_AT_CONSENSUS -> THROTTLED_AT_CONSENSUS; + case INVALID_PENDING_AIRDROP_ID -> INVALID_PENDING_AIRDROP_ID; + case TOKEN_AIRDROP_WITH_FALLBACK_ROYALTY -> TOKEN_AIRDROP_WITH_FALLBACK_ROYALTY; + case INVALID_TOKEN_IN_PENDING_AIRDROP -> INVALID_TOKEN_IN_PENDING_AIRDROP; case UNRECOGNIZED -> // NOTE: Protobuf deserialization will not give us the code on the wire throw new IllegalArgumentException( diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/TokenCancelAirdropTransaction.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/TokenCancelAirdropTransaction.java new file mode 100644 index 0000000000..9e2e7c9db5 --- /dev/null +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/TokenCancelAirdropTransaction.java @@ -0,0 +1,103 @@ +/*- + * + * Hedera Java SDK + * + * Copyright (C) 2020 - 2024 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.hashgraph.sdk; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.hedera.hashgraph.sdk.proto.SchedulableTransactionBody; +import com.hedera.hashgraph.sdk.proto.TokenCancelAirdropTransactionBody; +import com.hedera.hashgraph.sdk.proto.TokenServiceGrpc; +import com.hedera.hashgraph.sdk.proto.TransactionBody.Builder; +import com.hedera.hashgraph.sdk.proto.TransactionResponse; +import io.grpc.MethodDescriptor; +import java.util.LinkedHashMap; + +public class TokenCancelAirdropTransaction extends PendingAirdropLogic { + + /** + * Constructor. + */ + public TokenCancelAirdropTransaction() { + defaultMaxTransactionFee = Hbar.from(1); + } + + /** + * Constructor. + * + * @param txs Compound list of transaction id's list of (AccountId, Transaction) records + * @throws InvalidProtocolBufferException when there is an issue with the protobuf + */ + TokenCancelAirdropTransaction( + LinkedHashMap> txs) + throws InvalidProtocolBufferException { + super(txs); + initFromTransactionBody(); + } + + /** + * Constructor. + * + * @param txBody protobuf TransactionBody + */ + TokenCancelAirdropTransaction(com.hedera.hashgraph.sdk.proto.TransactionBody txBody) { + super(txBody); + initFromTransactionBody(); + } + + + /** + * Build the transaction body. + * + * @return {@link com.hedera.hashgraph.sdk.proto.TokenCancelAirdropTransactionBody} + */ + TokenCancelAirdropTransactionBody.Builder build() { + var builder = TokenCancelAirdropTransactionBody.newBuilder(); + + for (var pendingAirdropId : pendingAirdropIds) { + builder.addPendingAirdrops(pendingAirdropId.toProtobuf()); + } + + return builder; + } + + /** + * Initialize from the transaction body. + */ + void initFromTransactionBody() { + var body = sourceTransactionBody.getTokenCancelAirdrop(); + for (var pendingAirdropId : body.getPendingAirdropsList()) { + this.pendingAirdropIds.add(PendingAirdropId.fromProtobuf(pendingAirdropId)); + } + } + + @Override + MethodDescriptor getMethodDescriptor() { + return TokenServiceGrpc.getCancelAirdropMethod(); + } + + @Override + void onFreeze(Builder bodyBuilder) { + bodyBuilder.setTokenCancelAirdrop(build()); + } + + @Override + void onScheduled(SchedulableTransactionBody.Builder scheduled) { + scheduled.setTokenCancelAirdrop(build()); + } +} diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/TokenClaimAirdropTransaction.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/TokenClaimAirdropTransaction.java new file mode 100644 index 0000000000..cafcaa7b6c --- /dev/null +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/TokenClaimAirdropTransaction.java @@ -0,0 +1,102 @@ +/*- + * + * Hedera Java SDK + * + * Copyright (C) 2020 - 2024 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.hashgraph.sdk; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.hedera.hashgraph.sdk.proto.SchedulableTransactionBody; +import com.hedera.hashgraph.sdk.proto.TokenClaimAirdropTransactionBody; +import com.hedera.hashgraph.sdk.proto.TokenServiceGrpc; +import com.hedera.hashgraph.sdk.proto.TransactionBody.Builder; +import com.hedera.hashgraph.sdk.proto.TransactionResponse; +import io.grpc.MethodDescriptor; +import java.util.LinkedHashMap; + +public class TokenClaimAirdropTransaction extends PendingAirdropLogic { + + /** + * Constructor. + */ + public TokenClaimAirdropTransaction() { + defaultMaxTransactionFee = Hbar.from(1); + } + + /** + * Constructor. + * + * @param txs Compound list of transaction id's list of (AccountId, Transaction) records + * @throws InvalidProtocolBufferException when there is an issue with the protobuf + */ + TokenClaimAirdropTransaction( + LinkedHashMap> txs) + throws InvalidProtocolBufferException { + super(txs); + initFromTransactionBody(); + } + + /** + * Constructor. + * + * @param txBody protobuf TransactionBody + */ + TokenClaimAirdropTransaction(com.hedera.hashgraph.sdk.proto.TransactionBody txBody) { + super(txBody); + initFromTransactionBody(); + } + + /** + * Build the transaction body. + * + * @return {@link com.hedera.hashgraph.sdk.proto.TokenClaimAirdropTransactionBody} + */ + TokenClaimAirdropTransactionBody.Builder build() { + var builder = TokenClaimAirdropTransactionBody.newBuilder(); + + for (var pendingAirdropId : pendingAirdropIds) { + builder.addPendingAirdrops(pendingAirdropId.toProtobuf()); + } + + return builder; + } + + /** + * Initialize from the transaction body. + */ + void initFromTransactionBody() { + var body = sourceTransactionBody.getTokenClaimAirdrop(); + for (var pendingAirdropId : body.getPendingAirdropsList()) { + this.pendingAirdropIds.add(PendingAirdropId.fromProtobuf(pendingAirdropId)); + } + } + + @Override + MethodDescriptor getMethodDescriptor() { + return TokenServiceGrpc.getClaimAirdropMethod(); + } + + @Override + void onFreeze(Builder bodyBuilder) { + bodyBuilder.setTokenClaimAirdrop(build()); + } + + @Override + void onScheduled(SchedulableTransactionBody.Builder scheduled) { + scheduled.setTokenClaimAirdrop(build()); + } +} diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/Transaction.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/Transaction.java index 74fc7ecf0b..818b820b58 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/Transaction.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/Transaction.java @@ -359,6 +359,8 @@ public static Transaction fromBytes(byte[] bytes) throws InvalidProtocolBuffe case TOKEN_UNPAUSE -> new TokenUnpauseTransaction(txs); case TOKENREJECT -> new TokenRejectTransaction(txs); case TOKENAIRDROP -> new TokenAirdropTransaction(txs); + case TOKENCANCELAIRDROP -> new TokenCancelAirdropTransaction(txs); + case TOKENCLAIMAIRDROP -> new TokenClaimAirdropTransaction(txs); case CRYPTOAPPROVEALLOWANCE -> new AccountAllowanceApproveTransaction(txs); case CRYPTODELETEALLOWANCE -> new AccountAllowanceDeleteTransaction(txs); default -> throw new IllegalArgumentException("parsed transaction body has no data"); @@ -445,6 +447,8 @@ public static Transaction fromScheduledTransaction( case TOKENREJECT -> new TokenRejectTransaction(body.setTokenReject(scheduled.getTokenReject()).build()); case TOKENAIRDROP -> new TokenAirdropTransaction(body.setTokenAirdrop(scheduled.getTokenAirdrop()).build()); + case TOKENCANCELAIRDROP -> new TokenCancelAirdropTransaction(body.setTokenCancelAirdrop(scheduled.getTokenCancelAirdrop()).build()); + case TOKENCLAIMAIRDROP -> new TokenClaimAirdropTransaction(body.setTokenCancelAirdrop(scheduled.getTokenCancelAirdrop()).build()); case SCHEDULEDELETE -> new ScheduleDeleteTransaction(body.setScheduleDelete(scheduled.getScheduleDelete()).build()); default -> throw new IllegalStateException("schedulable transaction did not have a transaction set"); diff --git a/sdk/src/main/proto/account.proto b/sdk/src/main/proto/account.proto index 0eda66289c..3c7cfce552 100644 --- a/sdk/src/main/proto/account.proto +++ b/sdk/src/main/proto/account.proto @@ -214,6 +214,12 @@ message Account { * pending airdrop, and SHALL be empty otherwise. */ PendingAirdropId head_pending_airdrop_id = 34; + + /** + * The number of pending airdrops owned by the account. This number is used to collect rent + * for the account. + */ + uint64 number_pending_airdrops = 35; } /** diff --git a/sdk/src/main/proto/basic_types.proto b/sdk/src/main/proto/basic_types.proto index c19afc6cff..b3a1b2ae7f 100644 --- a/sdk/src/main/proto/basic_types.proto +++ b/sdk/src/main/proto/basic_types.proto @@ -66,6 +66,29 @@ message RealmID { int64 realmNum = 2; } +/** + * A specific hash algorithm. + * + * We did not reuse Record Stream `HashAlgorithm` here because in all cases, + * currently, this will be `SHA2_384` and if that is the default value then + * we can save space by not serializing it, whereas `HASH_ALGORITHM_UNKNOWN` + * is the default for Record Stream `HashAlgorithm`. + * + * Note that enum values here MUST NOT match the name of any other enum value + * in the same `package`, as protobuf follows `C++` scope rules and all enum + * _names_ are treated as global constants within the `package`. + */ +enum BlockHashAlgorithm { + /** + * A SHA2 algorithm SHA-384 hash. + *

+ * This is the default value, if a field of this enumerated type is + * not set, then this is the value that will be decoded when the + * serialized message is read. + */ + SHA2_384 = 0; +} + /** * The ID for an a cryptocurrency account */ @@ -647,7 +670,7 @@ message Key { * contractID key, which also requires the code in the active message frame belong to the * the contract with the given id.) */ - ContractID delegatable_contract_id = 8; + ContractID delegatable_contract_id = 8; } } diff --git a/sdk/src/main/proto/block_stream_info.proto b/sdk/src/main/proto/block_stream_info.proto new file mode 100644 index 0000000000..6bfbb79ea8 --- /dev/null +++ b/sdk/src/main/proto/block_stream_info.proto @@ -0,0 +1,88 @@ +/** + * # Block Stream Info + * Information stored in consensus state at the beginning of each block to + * record the status of the immediately prior block. + * + * ### Keywords + * The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + * "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this + * document are to be interpreted as described in + * [RFC2119](https://www.ietf.org/rfc/rfc2119) and clarified in + * [RFC8174](https://www.ietf.org/rfc/rfc8174). + */ +syntax = "proto3"; + +package com.hedera.hapi.node.state.blockstream; + +/* + * Copyright (C) 2024 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. + */ + +import "timestamp.proto"; + +option java_package = "com.hedera.hashgraph.sdk.proto"; +// <<>> This comment is special code for setting PBJ Compiler java package +option java_multiple_files = true; + +/** + * A message stored in state to maintain block stream parameters.
+ * Nodes use this information for three purposes. + * 1. To maintain hash chain continuity at restart and reconnect boundaries. + * 1. To store historical hashes for implementation of the EVM `BLOCKHASH` + * and `PREVRANDAO` opcodes. + * 1. To track the amount of consensus time that has passed between blocks. + * + * This value MUST be updated for every block.
+ * This value MUST be transmitted in the "state changes" section of + * _each_ block, but MUST be updated at the beginning of the _next_ block.
+ * This value SHALL contain the block hash up to, and including, the + * immediately prior completed block. + */ +message BlockStreamInfo { + /** + * A block number.
+ * This is the current block number. + */ + uint64 block_number = 1; + + /** + * A consensus time for the current block.
+ * This is the _first_ consensus time in the current block, and + * is used to determine if this block was the first across an + * important boundary in consensus time, such as UTC midnight. + * This may also be used to purge entities expiring between the last + * block time and this time. + */ + proto.Timestamp block_time = 2; + + /** + * A concatenation of hash values.
+ * This combines several trailing output block item hashes and + * is used as a seed value for a pseudo-random number generator.
+ * This is also requiried to implement the EVM `PREVRANDAO` opcode. + */ + bytes trailing_output_hashes = 3; + + /** + * A concatenation of hash values.
+ * This field combines up to 256 trailing block hashes. + *

+ * If this message is for block number N, then the earliest available + * hash SHALL be for block number N-256.
+ * The latest available hash SHALL be for block N-1.
+ * This is REQUIRED to implement the EVM `BLOCKHASH` opcode. + */ + bytes trailing_block_hashes = 4; +} diff --git a/sdk/src/main/proto/recordcache.proto b/sdk/src/main/proto/recordcache.proto index 093dc7685b..0682681fb4 100644 --- a/sdk/src/main/proto/recordcache.proto +++ b/sdk/src/main/proto/recordcache.proto @@ -24,6 +24,7 @@ package proto; import "basic_types.proto"; import "transaction_record.proto"; +import "response_code.proto"; option java_package = "com.hedera.hashgraph.sdk.proto"; // <<>> This comment is special code for setting PBJ Compiler java package @@ -53,3 +54,34 @@ message TransactionRecordEntry { */ TransactionRecord transaction_record = 3; } +/** + * As a single transaction is handled a receipt is created. It is stored in state for a configured time + * limit (perhaps, for example, 3 minutes). During this time window, any client can query the node and get the + * receipt for the transaction. The TransactionReceiptEntry is the object stored in state with this information. + */ +message TransactionReceiptEntry { + /** + * The ID of the node that submitted the transaction to consensus. The ID is the ID of the node as known by the + * address book. Valid node IDs are in the range 0..2^63-1, inclusive. + */ + uint64 node_id = 1; + + /** + * The id of the submitted transaction. + */ + TransactionID transaction_id = 2; + + /** + * The resulting status of handling the transaction. + */ + ResponseCodeEnum status = 3; +} +/** + * As transactions are handled and receipts are created, they are stored in state for a configured time + * limit (perhaps, for example, 3 minutes). During this time window, any client can query the node and get the + * receipt for the transaction. The TransactionReceiptEntries is the object stored in state with this information. + * This object contains a list of TransactionReceiptEntry objects. + */ +message TransactionReceiptEntries { + repeated TransactionReceiptEntry entries = 1; +} diff --git a/sdk/src/main/proto/response_code.proto b/sdk/src/main/proto/response_code.proto index 4d91c09c2a..752a012f74 100644 --- a/sdk/src/main/proto/response_code.proto +++ b/sdk/src/main/proto/response_code.proto @@ -1548,7 +1548,7 @@ enum ResponseCodeEnum { /** * The transaction attempted to use more than the allowed number of `PendingAirdropId`. */ - MAX_PENDING_AIRDROP_ID_EXCEEDED = 363; + PENDING_AIRDROP_ID_LIST_TOO_LONG = 363; /* * A pending airdrop already exists for the specified NFT. @@ -1562,4 +1562,35 @@ enum ResponseCodeEnum { * this transaction. */ ACCOUNT_HAS_PENDING_AIRDROPS = 365; + + /** + * Consensus throttle did not allow execution of this transaction.
+ * The transaction should be retried after a modest delay. + */ + THROTTLED_AT_CONSENSUS = 366; + + /** + * The provided pending airdrop id is invalid.
+ * This pending airdrop MAY already be claimed or cancelled. + *

+ * The client SHOULD query a mirror node to determine the current status of + * the pending airdrop. + */ + INVALID_PENDING_AIRDROP_ID = 367; + + /** + * The token to be airdropped has a fallback royalty fee and cannot be + * sent or claimed via an airdrop transaction. + */ + TOKEN_AIRDROP_WITH_FALLBACK_ROYALTY = 368; + + /** + * This airdrop claim is for a pending airdrop with an invalid token.
+ * The token might be deleted, or the sender may not have enough tokens + * to fulfill the offer. + *

+ * The client SHOULD query mirror node to determine the status of the pending + * airdrop and whether the sender can fulfill the offer. + */ + INVALID_TOKEN_IN_PENDING_AIRDROP = 369; } diff --git a/sdk/src/main/proto/roster.proto b/sdk/src/main/proto/roster.proto new file mode 100644 index 0000000000..24fd9f7786 --- /dev/null +++ b/sdk/src/main/proto/roster.proto @@ -0,0 +1,107 @@ +syntax = "proto3"; + +package com.hedera.hapi.node.state.roster; + +/* + * Copyright (C) 2024 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. + */ + +import "basic_types.proto"; + +option java_package = "com.hedera.hashgraph.sdk.proto"; +// <<>> This comment is special code for setting PBJ Compiler java package +option java_multiple_files = true; + +/** + * A single roster in the network state. + *

+ * The roster SHALL be a list of `RosterEntry` objects. + */ +message Roster { + + /** + * List of roster entries, one per consensus node. + *

+ * This list SHALL contain roster entries in natural order of ascending node ids. + * This list SHALL NOT be empty.
+ */ + repeated RosterEntry rosters = 1; +} + +/** + * A single roster entry in the network state. + * + * Each roster entry SHALL encapsulate the elements required + * to manage node participation in the Threshold Signature Scheme (TSS).
+ * All fields except tss_encryption_key are REQUIRED. + */ +message RosterEntry { + + /** + * A consensus node identifier. + *

+ * Node identifiers SHALL be unique _within_ a ledger, + * and MUST NOT be repeated _between_ shards and realms. + */ + uint64 node_id = 1; + + /** + * A consensus weight. + *

+ * Each node SHALL have a weight of zero or more in consensus calculations.
+ * The sum of the weights of all nodes in the roster SHALL form the total weight of the system, + * and each node's individual weight SHALL be proportional to that sum.
+ */ + uint64 weight = 2; + + /** + * An RSA public certificate used for signing gossip events. + *

+ * This value SHALL be a certificate of a type permitted for gossip + * signatures.
+ * This value SHALL be the DER encoding of the certificate presented.
+ * This field is REQUIRED and MUST NOT be empty. + */ + bytes gossip_ca_certificate = 3; + + /** + * An elliptic curve public encryption key.
+ * This is currently an ALT_BN128 curve, but the elliptic curve + * type may change in the future. For example, + * if the Ethereum ecosystem creates precompiles for BLS12_381, + * we may switch to that curve. + *

+ * This value SHALL be specified according to EIP-196 and EIP-197 standards, + * See EIP-196 and + * EIP-197
+ * This field is _initially_ OPTIONAL (i.e. it can be unset _when created_) + * but once set, it is REQUIRED thereafter. + */ + bytes tss_encryption_key = 4; + + /** + * A list of service endpoints for gossip. + *

+ * These endpoints SHALL represent the published endpoints to which other + * consensus nodes may _gossip_ transactions.
+ * If the network configuration value `gossipFqdnRestricted` is set, then + * all endpoints in this list SHALL supply only IP address.
+ * If the network configuration value `gossipFqdnRestricted` is _not_ set, + * then endpoints in this list MAY supply either IP address or FQDN, but + * SHALL NOT supply both values for the same endpoint.
+ * This list SHALL NOT be empty.
+ */ + repeated proto.ServiceEndpoint gossip_endpoint = 5; +} diff --git a/sdk/src/main/proto/roster_state.proto b/sdk/src/main/proto/roster_state.proto new file mode 100644 index 0000000000..6532694e13 --- /dev/null +++ b/sdk/src/main/proto/roster_state.proto @@ -0,0 +1,77 @@ +syntax = "proto3"; + +package com.hedera.hapi.node.state.roster; + +/* + * Copyright (C) 2024 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. + */ + +import "basic_types.proto"; + +option java_package = "com.hedera.hashgraph.sdk.proto"; +// <<>> This comment is special code for setting PBJ Compiler java package +option java_multiple_files = true; + +/** + * The current state of platform rosters.
+ * This message stores a roster data for the platform in network state. + * + * The roster state SHALL encapsulate the incoming candidate roster's hash, + * and a list of pairs of round number and active roster hash.
+ * This data SHALL be used to track round numbers and the rosters used in determining the consensus.
+ */ +message RosterState { + + /** + * The SHA-384 hash of a candidate roster. + *

+ * This is the hash of the roster that is currently being considered + * for adoption.
+ * A Node SHALL NOT, ever, have more than one candidate roster + * at the same time. + */ + bytes candidate_roster_hash = 1; + + /** + * A list of round numbers and roster hashes.
+ * The round number indicates the round in which the corresponding roster became active + *

+ * This list SHALL be ordered by round numbers in descending order. + */ + repeated RoundRosterPair round_roster_pairs = 2; +} + +/** + * A pair of round number and active roster hash. + *

+ * This message SHALL encapsulate the round number and the hash of the + * active roster used for that round. + */ +message RoundRosterPair { + + /** + * The round number. + *

+ * This value SHALL be the round number of the consensus round in which this roster became active. + */ + uint64 round_number = 1; + + /** + * The SHA-384 hash of the active roster for the given round number. + *

+ * This value SHALL be the hash of the active roster used for the round. + */ + bytes active_roster_hash = 2; +} diff --git a/sdk/src/main/proto/token_airdrop.proto b/sdk/src/main/proto/token_airdrop.proto index fb7b060996..cef39a2850 100644 --- a/sdk/src/main/proto/token_airdrop.proto +++ b/sdk/src/main/proto/token_airdrop.proto @@ -14,12 +14,9 @@ syntax = "proto3"; package proto; -/*- - * ‌ - * Hedera Network Services Protobuf - * ​ +/* * Copyright (C) 2024 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 @@ -31,7 +28,6 @@ package proto; * 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. - * ‍ */ option java_package = "com.hedera.hashgraph.sdk.proto"; diff --git a/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenCancelAirdropTransactionTest.java b/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenCancelAirdropTransactionTest.java new file mode 100644 index 0000000000..147bd25824 --- /dev/null +++ b/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenCancelAirdropTransactionTest.java @@ -0,0 +1,170 @@ +/*- + * + * Hedera Java SDK + * + * Copyright (C) 2020 - 2024 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.hashgraph.sdk; + +import com.hedera.hashgraph.sdk.proto.SchedulableTransactionBody; +import com.hedera.hashgraph.sdk.proto.TokenCancelAirdropTransactionBody; +import com.hedera.hashgraph.sdk.proto.TokenServiceGrpc; +import com.hedera.hashgraph.sdk.proto.TransactionBody; +import io.github.jsonSnapshot.SnapshotMatcher; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TokenCancelAirdropTransactionTest { + + private static final PrivateKey privateKey = PrivateKey.fromString( + "302e020100300506032b657004220420db484b828e64b2d8f12ce3c0a0e93a0b8cce7af1bb8f39c97732394482538e10"); + final Instant validStart = Instant.ofEpochSecond(1554158542); + private TokenCancelAirdropTransaction transaction; + + @BeforeAll + public static void beforeAll() { + SnapshotMatcher.start(); + } + + @AfterAll + public static void afterAll() { + SnapshotMatcher.validateSnapshots(); + } + + private TokenCancelAirdropTransaction spawnTestTransaction() { + List pendingAirdropIds = new ArrayList<>(); + pendingAirdropIds.add(new PendingAirdropId(new AccountId(0, 0, 457), new AccountId(0, 0, 456), + new TokenId(0, 0, 123))); + pendingAirdropIds.add(new PendingAirdropId(new AccountId(0, 0, 457), new AccountId(0, 0, 456), + new NftId(new TokenId(0, 0, 1234), 123))); + + return new TokenCancelAirdropTransaction() + .setNodeAccountIds(Arrays.asList(AccountId.fromString("0.0.5005"), AccountId.fromString("0.0.5006"))) + .setTransactionId(TransactionId.withValidStart(AccountId.fromString("0.0.5006"), validStart)) + .setMaxTransactionFee(Hbar.fromTinybars(100_000)) + .setPendingAirdropIds(pendingAirdropIds) + .freeze() + .sign(privateKey); + } + + @Test + void shouldSerialize() { + SnapshotMatcher.expect(spawnTestTransaction() + .toString() + ).toMatchSnapshot(); + } + + + @BeforeEach + public void setUp() { + transaction = new TokenCancelAirdropTransaction(); + } + + @Test + void testConstructorSetsDefaultMaxTransactionFee() { + Assertions.assertEquals(Hbar.from(1), transaction.getDefaultMaxTransactionFee()); + } + + @Test + void testGetAndSetPendingAirdropIds() { + List pendingAirdropIds = new ArrayList<>(); + pendingAirdropIds.add(new PendingAirdropId(new AccountId(0, 0, 457), new AccountId(0, 0, 456), + new TokenId(0, 0, 123))); + pendingAirdropIds.add(new PendingAirdropId(new AccountId(0, 0, 457), new AccountId(0, 0, 456), + new NftId(new TokenId(0, 0, 1234), 123))); + + transaction.setPendingAirdropIds(pendingAirdropIds); + + Assertions.assertEquals(pendingAirdropIds, transaction.getPendingAirdropIds()); + } + + @Test + void testSetPendingAirdropIdsNullThrowsException() { + Assertions.assertThrows(NullPointerException.class, () -> transaction.setPendingAirdropIds(null)); + } + + @Test + void testClearPendingAirdropIds() { + List pendingAirdropIds = new ArrayList<>(); + PendingAirdropId pendingAirdropId = new PendingAirdropId(new AccountId(0, 0, 457), new AccountId(0, 0, 456), + new TokenId(0, 0, 123)); + pendingAirdropIds.add(pendingAirdropId); + + transaction.setPendingAirdropIds(pendingAirdropIds); + transaction.clearPendingAirdropIds(); + + Assertions.assertTrue(transaction.getPendingAirdropIds().isEmpty()); + } + + @Test + void testAddAllPendingAirdrops() { + PendingAirdropId pendingAirdropId1 = new PendingAirdropId(new AccountId(0, 0, 457), new AccountId(0, 0, 456), + new TokenId(0, 0, 123)); + PendingAirdropId pendingAirdropId2 = new PendingAirdropId(new AccountId(0, 0, 458), new AccountId(0, 0, 459), + new TokenId(0, 0, 123)); + + transaction.addPendingAirdrop(pendingAirdropId1); + transaction.addPendingAirdrop(pendingAirdropId2); + + Assertions.assertEquals(2, transaction.getPendingAirdropIds().size()); + Assertions.assertTrue(transaction.getPendingAirdropIds().contains(pendingAirdropId1)); + Assertions.assertTrue(transaction.getPendingAirdropIds().contains(pendingAirdropId2)); + } + + @Test + void testAddAllPendingAirdropsNullThrowsException() { + Assertions.assertThrows(NullPointerException.class, () -> transaction.addPendingAirdrop(null)); + } + + @Test + void testBuildTransactionBody() { + PendingAirdropId pendingAirdropId = new PendingAirdropId(new AccountId(0, 0, 457), new AccountId(0, 0, 456), + new NftId(new TokenId(0, 0, 1234), 123)); + transaction.addPendingAirdrop(pendingAirdropId); + + TokenCancelAirdropTransactionBody.Builder builder = transaction.build(); + Assertions.assertEquals(1, builder.getPendingAirdropsCount()); + Assertions.assertEquals(pendingAirdropId.toProtobuf(), builder.getPendingAirdrops(0)); + } + + @Test + void testGetMethodDescriptor() { + Assertions.assertEquals(TokenServiceGrpc.getCancelAirdropMethod(), transaction.getMethodDescriptor()); + } + + @Test + void testOnFreeze() { + var bodyBuilder = TransactionBody.newBuilder(); + transaction.onFreeze(bodyBuilder); + + Assertions.assertTrue(bodyBuilder.hasTokenCancelAirdrop()); + } + + @Test + void testOnScheduled() { + SchedulableTransactionBody.Builder scheduled = SchedulableTransactionBody.newBuilder(); + transaction.onScheduled(scheduled); + + Assertions.assertTrue(scheduled.hasTokenCancelAirdrop()); + } +} diff --git a/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenCancelAirdropTransactionTest.snap b/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenCancelAirdropTransactionTest.snap new file mode 100644 index 0000000000..478020d764 --- /dev/null +++ b/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenCancelAirdropTransactionTest.snap @@ -0,0 +1,3 @@ +com.hedera.hashgraph.sdk.TokenCancelAirdropTransactionTest.shouldSerialize=[ + "# com.hedera.hashgraph.sdk.proto.TransactionBody\nnode_account_i_d {\n account_num: 5005\n realm_num: 0\n shard_num: 0\n}\ntoken_cancel_airdrop {\n pending_airdrops {\n fungible_token_type {\n realm_num: 0\n shard_num: 0\n token_num: 123\n }\n receiver_id {\n account_num: 456\n realm_num: 0\n shard_num: 0\n }\n sender_id {\n account_num: 457\n realm_num: 0\n shard_num: 0\n }\n }\n pending_airdrops {\n non_fungible_token {\n serial_number: 123\n token_i_d {\n realm_num: 0\n shard_num: 0\n token_num: 1234\n }\n }\n receiver_id {\n account_num: 456\n realm_num: 0\n shard_num: 0\n }\n sender_id {\n account_num: 457\n realm_num: 0\n shard_num: 0\n }\n }\n}\ntransaction_fee: 100000\ntransaction_i_d {\n account_i_d {\n account_num: 5006\n realm_num: 0\n shard_num: 0\n }\n transaction_valid_start {\n seconds: 1554158542\n }\n}\ntransaction_valid_duration {\n seconds: 120\n}" +] \ No newline at end of file diff --git a/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenClaimAirdropTransactionTest.java b/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenClaimAirdropTransactionTest.java new file mode 100644 index 0000000000..45a78258eb --- /dev/null +++ b/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenClaimAirdropTransactionTest.java @@ -0,0 +1,170 @@ +/*- + * + * Hedera Java SDK + * + * Copyright (C) 2020 - 2024 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.hashgraph.sdk; + +import com.hedera.hashgraph.sdk.proto.SchedulableTransactionBody; +import com.hedera.hashgraph.sdk.proto.TokenClaimAirdropTransactionBody; +import com.hedera.hashgraph.sdk.proto.TokenServiceGrpc; +import com.hedera.hashgraph.sdk.proto.TransactionBody; +import io.github.jsonSnapshot.SnapshotMatcher; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TokenClaimAirdropTransactionTest { + + private static final PrivateKey privateKey = PrivateKey.fromString( + "302e020100300506032b657004220420db484b828e64b2d8f12ce3c0a0e93a0b8cce7af1bb8f39c97732394482538e10"); + final Instant validStart = Instant.ofEpochSecond(1554158542); + private TokenClaimAirdropTransaction transaction; + + @BeforeAll + public static void beforeAll() { + SnapshotMatcher.start(); + } + + @AfterAll + public static void afterAll() { + SnapshotMatcher.validateSnapshots(); + } + + private TokenClaimAirdropTransaction spawnTestTransaction() { + List pendingAirdropIds = new ArrayList<>(); + pendingAirdropIds.add(new PendingAirdropId(new AccountId(0, 0, 457), new AccountId(0, 0, 456), + new TokenId(0, 0, 123))); + pendingAirdropIds.add(new PendingAirdropId(new AccountId(0, 0, 457), new AccountId(0, 0, 456), + new NftId(new TokenId(0, 0, 1234), 123))); + + return new TokenClaimAirdropTransaction() + .setNodeAccountIds(Arrays.asList(AccountId.fromString("0.0.5005"), AccountId.fromString("0.0.5006"))) + .setTransactionId(TransactionId.withValidStart(AccountId.fromString("0.0.5006"), validStart)) + .setMaxTransactionFee(Hbar.fromTinybars(100_000)) + .setPendingAirdropIds(pendingAirdropIds) + .freeze() + .sign(privateKey); + } + + @Test + void shouldSerialize() { + SnapshotMatcher.expect(spawnTestTransaction() + .toString() + ).toMatchSnapshot(); + } + + + @BeforeEach + public void setUp() { + transaction = new TokenClaimAirdropTransaction(); + } + + @Test + void testConstructorSetsDefaultMaxTransactionFee() { + Assertions.assertEquals(Hbar.from(1), transaction.getDefaultMaxTransactionFee()); + } + + @Test + void testGetAndSetPendingAirdropIds() { + List pendingAirdropIds = new ArrayList<>(); + pendingAirdropIds.add(new PendingAirdropId(new AccountId(0, 0, 457), new AccountId(0, 0, 456), + new TokenId(0, 0, 123))); + pendingAirdropIds.add(new PendingAirdropId(new AccountId(0, 0, 457), new AccountId(0, 0, 456), + new NftId(new TokenId(0, 0, 1234), 123))); + + transaction.setPendingAirdropIds(pendingAirdropIds); + + Assertions.assertEquals(pendingAirdropIds, transaction.getPendingAirdropIds()); + } + + @Test + void testSetPendingAirdropIdsNullThrowsException() { + Assertions.assertThrows(NullPointerException.class, () -> transaction.setPendingAirdropIds(null)); + } + + @Test + void testClearPendingAirdropIds() { + List pendingAirdropIds = new ArrayList<>(); + PendingAirdropId pendingAirdropId = new PendingAirdropId(new AccountId(0, 0, 457), new AccountId(0, 0, 456), + new TokenId(0, 0, 123)); + pendingAirdropIds.add(pendingAirdropId); + + transaction.setPendingAirdropIds(pendingAirdropIds); + transaction.clearPendingAirdropIds(); + + Assertions.assertTrue(transaction.getPendingAirdropIds().isEmpty()); + } + + @Test + void testAddAllPendingAirdrops() { + PendingAirdropId pendingAirdropId1 = new PendingAirdropId(new AccountId(0, 0, 457), new AccountId(0, 0, 456), + new TokenId(0, 0, 123)); + PendingAirdropId pendingAirdropId2 = new PendingAirdropId(new AccountId(0, 0, 458), new AccountId(0, 0, 459), + new TokenId(0, 0, 123)); + + transaction.addPendingAirdrop(pendingAirdropId1); + transaction.addPendingAirdrop(pendingAirdropId2); + + Assertions.assertEquals(2, transaction.getPendingAirdropIds().size()); + Assertions.assertTrue(transaction.getPendingAirdropIds().contains(pendingAirdropId1)); + Assertions.assertTrue(transaction.getPendingAirdropIds().contains(pendingAirdropId2)); + } + + @Test + void testAddAllPendingAirdropsNullThrowsException() { + Assertions.assertThrows(NullPointerException.class, () -> transaction.addPendingAirdrop(null)); + } + + @Test + void testBuildTransactionBody() { + PendingAirdropId pendingAirdropId = new PendingAirdropId(new AccountId(0, 0, 457), new AccountId(0, 0, 456), + new NftId(new TokenId(0, 0, 1234), 123)); + transaction.addPendingAirdrop(pendingAirdropId); + + TokenClaimAirdropTransactionBody.Builder builder = transaction.build(); + Assertions.assertEquals(1, builder.getPendingAirdropsCount()); + Assertions.assertEquals(pendingAirdropId.toProtobuf(), builder.getPendingAirdrops(0)); + } + + @Test + void testGetMethodDescriptor() { + Assertions.assertEquals(TokenServiceGrpc.getClaimAirdropMethod(), transaction.getMethodDescriptor()); + } + + @Test + void testOnFreeze() { + var bodyBuilder = TransactionBody.newBuilder(); + transaction.onFreeze(bodyBuilder); + + Assertions.assertTrue(bodyBuilder.hasTokenClaimAirdrop()); + } + + @Test + void testOnScheduled() { + SchedulableTransactionBody.Builder scheduled = SchedulableTransactionBody.newBuilder(); + transaction.onScheduled(scheduled); + + Assertions.assertTrue(scheduled.hasTokenClaimAirdrop()); + } +} diff --git a/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenClaimAirdropTransactionTest.snap b/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenClaimAirdropTransactionTest.snap new file mode 100644 index 0000000000..451ab57cd4 --- /dev/null +++ b/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenClaimAirdropTransactionTest.snap @@ -0,0 +1,3 @@ +com.hedera.hashgraph.sdk.TokenClaimAirdropTransactionTest.shouldSerialize=[ + "# com.hedera.hashgraph.sdk.proto.TransactionBody\nnode_account_i_d {\n account_num: 5005\n realm_num: 0\n shard_num: 0\n}\ntoken_claim_airdrop {\n pending_airdrops {\n fungible_token_type {\n realm_num: 0\n shard_num: 0\n token_num: 123\n }\n receiver_id {\n account_num: 456\n realm_num: 0\n shard_num: 0\n }\n sender_id {\n account_num: 457\n realm_num: 0\n shard_num: 0\n }\n }\n pending_airdrops {\n non_fungible_token {\n serial_number: 123\n token_i_d {\n realm_num: 0\n shard_num: 0\n token_num: 1234\n }\n }\n receiver_id {\n account_num: 456\n realm_num: 0\n shard_num: 0\n }\n sender_id {\n account_num: 457\n realm_num: 0\n shard_num: 0\n }\n }\n}\ntransaction_fee: 100000\ntransaction_i_d {\n account_i_d {\n account_num: 5006\n realm_num: 0\n shard_num: 0\n }\n transaction_valid_start {\n seconds: 1554158542\n }\n}\ntransaction_valid_duration {\n seconds: 120\n}" +] \ No newline at end of file diff --git a/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/TokenAirdropCancelIntegrationTest.java b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/TokenAirdropCancelIntegrationTest.java new file mode 100644 index 0000000000..81983cd9b5 --- /dev/null +++ b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/TokenAirdropCancelIntegrationTest.java @@ -0,0 +1,482 @@ +/*- + * + * Hedera Java SDK + * + * Copyright (C) 2020 - 2024 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.hashgraph.sdk.test.integration; + +import static com.hedera.hashgraph.sdk.test.integration.EntityHelper.fungibleInitialBalance; +import static com.hedera.hashgraph.sdk.test.integration.EntityHelper.mitedNfts; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.hedera.hashgraph.sdk.AccountBalanceQuery; +import com.hedera.hashgraph.sdk.PendingAirdropId; +import com.hedera.hashgraph.sdk.PendingAirdropRecord; +import com.hedera.hashgraph.sdk.PrecheckStatusException; +import com.hedera.hashgraph.sdk.PrivateKey; +import com.hedera.hashgraph.sdk.ReceiptStatusException; +import com.hedera.hashgraph.sdk.Status; +import com.hedera.hashgraph.sdk.TokenAirdropTransaction; +import com.hedera.hashgraph.sdk.TokenAssociateTransaction; +import com.hedera.hashgraph.sdk.TokenCancelAirdropTransaction; +import com.hedera.hashgraph.sdk.TokenDeleteTransaction; +import com.hedera.hashgraph.sdk.TokenFreezeTransaction; +import com.hedera.hashgraph.sdk.TokenMintTransaction; +import com.hedera.hashgraph.sdk.TokenPauseTransaction; +import com.hedera.hashgraph.sdk.TransactionId; +import java.util.ArrayList; +import java.util.Collections; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class TokenAirdropCancelIntegrationTest { + + private final int amount = 100; + + @Test + @DisplayName("Cancels the tokens when they are in pending state") + void canCancelTokens() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible and nf token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + var nftID = EntityHelper.createNft(testEnv); + // mint some NFTs + var mintReceipt = new TokenMintTransaction() + .setTokenId(nftID) + .setMetadata(NftMetadataGenerator.generate((byte) 10)) + .execute(testEnv.client) + .getReceipt(testEnv.client); + var nftSerials = mintReceipt.serials; + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var record = new TokenAirdropTransaction() + .addNftTransfer(nftID.nft(nftSerials.get(0)), testEnv.operatorId, receiverAccountId) + .addNftTransfer(nftID.nft(nftSerials.get(1)), testEnv.operatorId, receiverAccountId) + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // sender cancels the tokens + record = new TokenCancelAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .addPendingAirdrop(record.pendingAirdropRecords.get(1).getPendingAirdropId()) + .addPendingAirdrop(record.pendingAirdropRecords.get(2).getPendingAirdropId()) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // verify in the transaction record the pending airdrop ids for nft and ft - should no longer exist + assertEquals(0, record.pendingAirdropRecords.size()); + + // verify the receiver does not hold the tokens via query + var receiverAccountBalance = new AccountBalanceQuery() + .setAccountId(receiverAccountId) + .execute(testEnv.client); + assertNull(receiverAccountBalance.tokens.get(tokenID)); + assertNull(receiverAccountBalance.tokens.get(nftID)); + + // verify the operator does hold the tokens + var operatorBalance = new AccountBalanceQuery() + .setAccountId(testEnv.operatorId) + .execute(testEnv.client); + assertEquals(fungibleInitialBalance, operatorBalance.tokens.get(tokenID)); + assertEquals(mitedNfts, operatorBalance.tokens.get(nftID)); + + testEnv.close(); + } + + @Test + @DisplayName("Cancels the tokens when token is frozen") + void canCancelTokensWhenTokenIsFrozen() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var record = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // associate + new TokenAssociateTransaction() + .setAccountId(receiverAccountId) + .setTokenIds(Collections.singletonList(tokenID)) + .freezeWith(testEnv.client) + .sign(receiverAccountKey) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + // freeze the token + new TokenFreezeTransaction() + .setAccountId(receiverAccountId) + .setTokenId(tokenID) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + // cancel + new TokenCancelAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .execute(testEnv.client) + .getRecord(testEnv.client); + + testEnv.close(); + } + + @Test + @DisplayName("Cancels the tokens when token is paused") + void canCancelTokensWhenTokenIsPaused() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var record = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // pause the token + new TokenPauseTransaction().setTokenId(tokenID).execute(testEnv.client).getReceipt(testEnv.client); + + // cancel + new TokenCancelAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .execute(testEnv.client) + .getRecord(testEnv.client); + + testEnv.close(); + } + + @Test + @DisplayName("Cancels the tokens when token is deleted") + void canCancelTokensWhenTokenIsDeleted() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var record = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // delete the token + new TokenDeleteTransaction().setTokenId(tokenID).execute(testEnv.client).getReceipt(testEnv.client); + + // cancel + new TokenCancelAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .execute(testEnv.client) + .getRecord(testEnv.client); + + testEnv.close(); + } + + @Test + @DisplayName("Cancels the tokens when they are in pending state to multiple receivers") + void canCancelTokensToMultipleReceivers() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible and nf token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + var nftID = EntityHelper.createNft(testEnv); + // mint some NFTs + var mintReceipt = new TokenMintTransaction() + .setTokenId(nftID) + .setMetadata(NftMetadataGenerator.generate((byte) 10)) + .execute(testEnv.client) + .getReceipt(testEnv.client); + var nftSerials = mintReceipt.serials; + + // create receiver1 with 0 auto associations + var receiver1AccountKey = PrivateKey.generateED25519(); + var receiver1AccountId = EntityHelper.createAccount(testEnv, receiver1AccountKey, 0); + + // create receiver2 with 0 auto associations + var receiver2AccountKey = PrivateKey.generateED25519(); + var receiver2AccountId = EntityHelper.createAccount(testEnv, receiver2AccountKey, 0); + + // airdrop the tokens to both + var record = new TokenAirdropTransaction() + .addNftTransfer(nftID.nft(nftSerials.get(0)), testEnv.operatorId, receiver1AccountId) + .addNftTransfer(nftID.nft(nftSerials.get(1)), testEnv.operatorId, receiver1AccountId) + .addTokenTransfer(tokenID, receiver1AccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .addNftTransfer(nftID.nft(nftSerials.get(2)), testEnv.operatorId, receiver2AccountId) + .addNftTransfer(nftID.nft(nftSerials.get(3)), testEnv.operatorId, receiver2AccountId) + .addTokenTransfer(tokenID, receiver2AccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // verify the txn record + assertEquals(6, record.pendingAirdropRecords.size()); + + // cancel the tokens signing with receiver1 and receiver2 + var pendingAirdropIDs = record.pendingAirdropRecords.stream().map(PendingAirdropRecord::getPendingAirdropId) + .toList(); + record = new TokenCancelAirdropTransaction() + .setPendingAirdropIds(pendingAirdropIDs) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // verify in the transaction record the pending airdrop ids for nft and ft - should no longer exist + assertEquals(0, record.pendingAirdropRecords.size()); + + // verify receiver1 does not hold the tokens via query + var receiverAccountBalance = new AccountBalanceQuery() + .setAccountId(receiver1AccountId) + .execute(testEnv.client); + assertNull(receiverAccountBalance.tokens.get(tokenID)); + assertNull(receiverAccountBalance.tokens.get(nftID)); + + // verify receiver2 does not hold the tokens via query + var receiver2AccountBalance = new AccountBalanceQuery() + .setAccountId(receiver1AccountId) + .execute(testEnv.client); + assertNull(receiver2AccountBalance.tokens.get(tokenID)); + assertNull(receiver2AccountBalance.tokens.get(nftID)); + + // verify the operator does hold the tokens + var operatorBalance = new AccountBalanceQuery() + .setAccountId(testEnv.operatorId) + .execute(testEnv.client); + assertEquals(fungibleInitialBalance, operatorBalance.tokens.get(tokenID)); + assertEquals(mitedNfts, operatorBalance.tokens.get(nftID)); + + testEnv.close(); + } + + + @Test + @DisplayName("Cancels the tokens when they are in pending state from multiple airdrop transactions") + void canCancelTokensFromMultipleAirdropTxns() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible and nf token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + var nftID = EntityHelper.createNft(testEnv); + // mint some NFTs + var mintReceipt = new TokenMintTransaction() + .setTokenId(nftID) + .setMetadata(NftMetadataGenerator.generate((byte) 10)) + .execute(testEnv.client) + .getReceipt(testEnv.client); + var nftSerials = mintReceipt.serials; + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop some of the tokens to the receiver + var record1 = new TokenAirdropTransaction() + .addNftTransfer(nftID.nft(nftSerials.get(0)), testEnv.operatorId, receiverAccountId) + .execute(testEnv.client) + .getRecord(testEnv.client); + // airdrop some of the tokens to the receiver + var record2 = new TokenAirdropTransaction() + .addNftTransfer(nftID.nft(nftSerials.get(1)), testEnv.operatorId, receiverAccountId) + .execute(testEnv.client) + .getRecord(testEnv.client); + // airdrop some of the tokens to the receiver + var record3 = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // get the PendingIds from the records + var pendingAirdropIDs = new ArrayList(); + pendingAirdropIDs.add(record1.pendingAirdropRecords.get(0).getPendingAirdropId()); + pendingAirdropIDs.add(record2.pendingAirdropRecords.get(0).getPendingAirdropId()); + pendingAirdropIDs.add(record3.pendingAirdropRecords.get(0).getPendingAirdropId()); + + // cancel the all the tokens with the receiver + var record = new TokenCancelAirdropTransaction() + .setPendingAirdropIds(pendingAirdropIDs) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // verify in the transaction record the pending airdrop ids for nft and ft - should no longer exist + assertEquals(0, record.pendingAirdropRecords.size()); + + // verify the receiver does not hold the tokens via query + var receiverAccountBalance = new AccountBalanceQuery() + .setAccountId(receiverAccountId) + .execute(testEnv.client); + assertNull(receiverAccountBalance.tokens.get(tokenID)); + assertNull(receiverAccountBalance.tokens.get(nftID)); + + // verify the operator does hold the tokens + var operatorBalance = new AccountBalanceQuery() + .setAccountId(testEnv.operatorId) + .execute(testEnv.client); + assertEquals(fungibleInitialBalance, operatorBalance.tokens.get(tokenID)); + assertEquals(mitedNfts, operatorBalance.tokens.get(nftID)); + + testEnv.close(); + } + + @Test + @DisplayName("Cannot cancel the tokens when they are not airdropped") + void cannotCancelTokensForNonExistingAirdrop() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var record = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // create receiver with 0 auto associations + var randomAccountKey = PrivateKey.generateED25519(); + var randomAccount = EntityHelper.createAccount(testEnv, randomAccountKey, 0); + + + // cancel the tokens with the random account which has not created pending airdrops + // fails with INVALID_SIGNATURE + assertThatExceptionOfType(PrecheckStatusException.class).isThrownBy(() -> { + new TokenCancelAirdropTransaction() + .setTransactionId(TransactionId.generate(randomAccount)) + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .execute(testEnv.client) + .getRecord(testEnv.client); + }).withMessageContaining(Status.INVALID_SIGNATURE.toString()); + + testEnv.close(); + } + + @Test + @DisplayName("Cannot cancel the tokens when they are already canceled") + void canonCancelTokensForAlreadyCanceledAirdrop() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var record = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // cancel the tokens with the receiver + new TokenCancelAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // cancel the tokens with the receiver again + // fails with INVALID_PENDING_AIRDROP_ID + assertThatExceptionOfType(ReceiptStatusException.class).isThrownBy(() -> { + new TokenCancelAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .execute(testEnv.client) + .getRecord(testEnv.client); + }).withMessageContaining(Status.INVALID_PENDING_AIRDROP_ID.toString()); + + testEnv.close(); + } + + + @Test + @DisplayName("Cannot cancel the tokens with empty list") + void canonCancelWithEmptyPendingAirdropsList() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // cancel the tokens with the receiver without setting pendingAirdropIds + // fails with EMPTY_PENDING_AIRDROP_ID_LIST + assertThatExceptionOfType(PrecheckStatusException.class).isThrownBy(() -> { + new TokenCancelAirdropTransaction() + .execute(testEnv.client) + .getRecord(testEnv.client); + }).withMessageContaining(Status.EMPTY_PENDING_AIRDROP_ID_LIST.toString()); + + testEnv.close(); + } + + @Test + @DisplayName("Cannot cancel the tokens with duplicate entries") + void cannotCancelTokensWithDuplicateEntries() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var record = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // cancel the tokens with duplicate pending airdrop token ids + // fails with PENDING_AIRDROP_ID_REPEATED + assertThatExceptionOfType(PrecheckStatusException.class).isThrownBy(() -> { + new TokenCancelAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .execute(testEnv.client) + .getRecord(testEnv.client); + }).withMessageContaining(Status.PENDING_AIRDROP_ID_REPEATED.toString()); + + testEnv.close(); + } +} diff --git a/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/TokenAirdropClaimIntegrationTest.java b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/TokenAirdropClaimIntegrationTest.java new file mode 100644 index 0000000000..80eb956f5e --- /dev/null +++ b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/TokenAirdropClaimIntegrationTest.java @@ -0,0 +1,514 @@ +/*- + * + * Hedera Java SDK + * + * Copyright (C) 2020 - 2024 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.hashgraph.sdk.test.integration; + +import static com.hedera.hashgraph.sdk.test.integration.EntityHelper.fungibleInitialBalance; +import static com.hedera.hashgraph.sdk.test.integration.EntityHelper.mitedNfts; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.hedera.hashgraph.sdk.AccountBalanceQuery; +import com.hedera.hashgraph.sdk.PendingAirdropId; +import com.hedera.hashgraph.sdk.PendingAirdropRecord; +import com.hedera.hashgraph.sdk.PrecheckStatusException; +import com.hedera.hashgraph.sdk.PrivateKey; +import com.hedera.hashgraph.sdk.ReceiptStatusException; +import com.hedera.hashgraph.sdk.Status; +import com.hedera.hashgraph.sdk.TokenAirdropTransaction; +import com.hedera.hashgraph.sdk.TokenAssociateTransaction; +import com.hedera.hashgraph.sdk.TokenClaimAirdropTransaction; +import com.hedera.hashgraph.sdk.TokenDeleteTransaction; +import com.hedera.hashgraph.sdk.TokenFreezeTransaction; +import com.hedera.hashgraph.sdk.TokenMintTransaction; +import com.hedera.hashgraph.sdk.TokenPauseTransaction; +import java.util.ArrayList; +import java.util.Collections; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class TokenAirdropClaimIntegrationTest { + + private final int amount = 100; + + @Test + @DisplayName("Claims the tokens when they are in pending state") + void canClaimTokens() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible and nf token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + var nftID = EntityHelper.createNft(testEnv); + // mint some NFTs + var mintReceipt = new TokenMintTransaction() + .setTokenId(nftID) + .setMetadata(NftMetadataGenerator.generate((byte) 10)) + .execute(testEnv.client) + .getReceipt(testEnv.client); + var nftSerials = mintReceipt.serials; + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var record = new TokenAirdropTransaction() + .addNftTransfer(nftID.nft(nftSerials.get(0)), testEnv.operatorId, receiverAccountId) + .addNftTransfer(nftID.nft(nftSerials.get(1)), testEnv.operatorId, receiverAccountId) + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // verify the txn record + assertEquals(3, record.pendingAirdropRecords.size()); + + assertEquals(100, record.pendingAirdropRecords.get(0).getPendingAirdropAmount()); + assertEquals(tokenID, record.pendingAirdropRecords.get(0).getPendingAirdropId().getTokenId()); + assertNull(record.pendingAirdropRecords.get(0).getPendingAirdropId().getNftId()); + + assertEquals(0, record.pendingAirdropRecords.get(1).getPendingAirdropAmount()); + assertEquals(nftID.nft(1), record.pendingAirdropRecords.get(1).getPendingAirdropId().getNftId()); + assertNull(record.pendingAirdropRecords.get(1).getPendingAirdropId().getTokenId()); + + assertEquals(0, record.pendingAirdropRecords.get(2).getPendingAirdropAmount()); + assertEquals(nftID.nft(2), record.pendingAirdropRecords.get(2).getPendingAirdropId().getNftId()); + assertNull(record.pendingAirdropRecords.get(2).getPendingAirdropId().getTokenId()); + + // claim the tokens with the receiver + record = new TokenClaimAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .addPendingAirdrop(record.pendingAirdropRecords.get(1).getPendingAirdropId()) + .addPendingAirdrop(record.pendingAirdropRecords.get(2).getPendingAirdropId()) + .freezeWith(testEnv.client) + .sign(receiverAccountKey) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // verify in the transaction record the pending airdrop ids for nft and ft - should no longer exist + assertEquals(0, record.pendingAirdropRecords.size()); + + // verify the receiver holds the tokens via query + var receiverAccountBalance = new AccountBalanceQuery() + .setAccountId(receiverAccountId) + .execute(testEnv.client); + assertEquals(amount, receiverAccountBalance.tokens.get(tokenID)); + assertEquals(2, receiverAccountBalance.tokens.get(nftID)); + + // verify the operator does not hold the tokens + var operatorBalance = new AccountBalanceQuery() + .setAccountId(testEnv.operatorId) + .execute(testEnv.client); + assertEquals(fungibleInitialBalance - amount, operatorBalance.tokens.get(tokenID)); + assertEquals(mitedNfts - 2, operatorBalance.tokens.get(nftID)); + + testEnv.close(); + } + + @Test + @DisplayName("Claims the tokens when they are in pending state to multiple receivers") + void canClaimTokensToMultipleReceivers() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible and nf token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + var nftID = EntityHelper.createNft(testEnv); + // mint some NFTs + var mintReceipt = new TokenMintTransaction() + .setTokenId(nftID) + .setMetadata(NftMetadataGenerator.generate((byte) 10)) + .execute(testEnv.client) + .getReceipt(testEnv.client); + var nftSerials = mintReceipt.serials; + + // create receiver1 with 0 auto associations + var receiver1AccountKey = PrivateKey.generateED25519(); + var receiver1AccountId = EntityHelper.createAccount(testEnv, receiver1AccountKey, 0); + + // create receiver2 with 0 auto associations + var receiver2AccountKey = PrivateKey.generateED25519(); + var receiver2AccountId = EntityHelper.createAccount(testEnv, receiver2AccountKey, 0); + + // airdrop the tokens to both + var record = new TokenAirdropTransaction() + .addNftTransfer(nftID.nft(nftSerials.get(0)), testEnv.operatorId, receiver1AccountId) + .addNftTransfer(nftID.nft(nftSerials.get(1)), testEnv.operatorId, receiver1AccountId) + .addTokenTransfer(tokenID, receiver1AccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .addNftTransfer(nftID.nft(nftSerials.get(2)), testEnv.operatorId, receiver2AccountId) + .addNftTransfer(nftID.nft(nftSerials.get(3)), testEnv.operatorId, receiver2AccountId) + .addTokenTransfer(tokenID, receiver2AccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // verify the txn record + assertEquals(6, record.pendingAirdropRecords.size()); + + // claim the tokens signing with receiver1 and receiver2 + var pendingAirdropIDs = record.pendingAirdropRecords.stream().map(PendingAirdropRecord::getPendingAirdropId) + .toList(); + record = new TokenClaimAirdropTransaction() + .setPendingAirdropIds(pendingAirdropIDs) + .freezeWith(testEnv.client) + .sign(receiver1AccountKey) + .sign(receiver2AccountKey) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // verify in the transaction record the pending airdrop ids for nft and ft - should no longer exist + assertEquals(0, record.pendingAirdropRecords.size()); + // verify receiver1 holds the tokens via query + var receiverAccountBalance = new AccountBalanceQuery() + .setAccountId(receiver1AccountId) + .execute(testEnv.client); + assertEquals(amount, receiverAccountBalance.tokens.get(tokenID)); + assertEquals(2, receiverAccountBalance.tokens.get(nftID)); + + // verify receiver2 holds the tokens via query + var receiver2AccountBalance = new AccountBalanceQuery() + .setAccountId(receiver1AccountId) + .execute(testEnv.client); + assertEquals(amount, receiver2AccountBalance.tokens.get(tokenID)); + assertEquals(2, receiver2AccountBalance.tokens.get(nftID)); + + // verify the operator does not hold the tokens + var operatorBalance = new AccountBalanceQuery() + .setAccountId(testEnv.operatorId) + .execute(testEnv.client); + assertEquals(fungibleInitialBalance - amount * 2, operatorBalance.tokens.get(tokenID)); + assertEquals(mitedNfts - 4, operatorBalance.tokens.get(nftID)); + + testEnv.close(); + } + + + @Test + @DisplayName("Claims the tokens when they are in pending state from multiple airdrop transactions") + void canClaimTokensFromMultipleAirdropTxns() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible and nf token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + var nftID = EntityHelper.createNft(testEnv); + // mint some NFTs + var mintReceipt = new TokenMintTransaction() + .setTokenId(nftID) + .setMetadata(NftMetadataGenerator.generate((byte) 10)) + .execute(testEnv.client) + .getReceipt(testEnv.client); + var nftSerials = mintReceipt.serials; + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop some of the tokens to the receiver + var record1 = new TokenAirdropTransaction() + .addNftTransfer(nftID.nft(nftSerials.get(0)), testEnv.operatorId, receiverAccountId) + .execute(testEnv.client) + .getRecord(testEnv.client); + // airdrop some of the tokens to the receiver + var record2 = new TokenAirdropTransaction() + .addNftTransfer(nftID.nft(nftSerials.get(1)), testEnv.operatorId, receiverAccountId) + .execute(testEnv.client) + .getRecord(testEnv.client); + // airdrop some of the tokens to the receiver + var record3 = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // get the PendingIds from the records + var pendingAirdropIDs = new ArrayList(); + pendingAirdropIDs.add(record1.pendingAirdropRecords.get(0).getPendingAirdropId()); + pendingAirdropIDs.add(record2.pendingAirdropRecords.get(0).getPendingAirdropId()); + pendingAirdropIDs.add(record3.pendingAirdropRecords.get(0).getPendingAirdropId()); + + // claim the all the tokens with the receiver + var record = new TokenClaimAirdropTransaction() + .setPendingAirdropIds(pendingAirdropIDs) + .freezeWith(testEnv.client) + .sign(receiverAccountKey) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // verify in the transaction record the pending airdrop ids for nft and ft - should no longer exist + assertEquals(0, record.pendingAirdropRecords.size()); + + // verify the receiver holds the tokens via query + var receiverAccountBalance = new AccountBalanceQuery() + .setAccountId(receiverAccountId) + .execute(testEnv.client); + assertEquals(amount, receiverAccountBalance.tokens.get(tokenID)); + assertEquals(2, receiverAccountBalance.tokens.get(nftID)); + + // verify the operator does not hold the tokens + var operatorBalance = new AccountBalanceQuery() + .setAccountId(testEnv.operatorId) + .execute(testEnv.client); + assertEquals(fungibleInitialBalance - amount, operatorBalance.tokens.get(tokenID)); + assertEquals(mitedNfts - 2, operatorBalance.tokens.get(nftID)); + + testEnv.close(); + } + + @Test + @DisplayName("Cannot claim the tokens when they are not airdropped") + void cannotClaimTokensForNonExistingAirdrop() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var record = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // claim the tokens with the operator which does not have pending airdrops + // fails with INVALID_SIGNATURE + assertThatExceptionOfType(ReceiptStatusException.class).isThrownBy(() -> { + new TokenClaimAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .execute(testEnv.client) + .getRecord(testEnv.client); + }).withMessageContaining(Status.INVALID_SIGNATURE.toString()); + + testEnv.close(); + } + + @Test + @DisplayName("Cannot claim the tokens when they are already claimed") + void cannotClaimTokensForAlreadyClaimedAirdrop() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var record = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // claim the tokens with the receiver + new TokenClaimAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .freezeWith(testEnv.client) + .sign(receiverAccountKey) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // claim the tokens with the receiver again + // fails with INVALID_PENDING_AIRDROP_ID + assertThatExceptionOfType(ReceiptStatusException.class).isThrownBy(() -> { + new TokenClaimAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .freezeWith(testEnv.client) + .sign(receiverAccountKey) + .execute(testEnv.client) + .getRecord(testEnv.client); + }).withMessageContaining(Status.INVALID_PENDING_AIRDROP_ID.toString()); + + testEnv.close(); + } + + @Test + @DisplayName("Cannot claim the tokens with empty list") + void cannotClaimWithEmptyPendingAirdropsList() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // claim the tokens with the receiver without setting pendingAirdropIds + // fails with EMPTY_PENDING_AIRDROP_ID_LIST + assertThatExceptionOfType(PrecheckStatusException.class).isThrownBy(() -> { + new TokenClaimAirdropTransaction() + .execute(testEnv.client) + .getRecord(testEnv.client); + }).withMessageContaining(Status.EMPTY_PENDING_AIRDROP_ID_LIST.toString()); + + testEnv.close(); + } + + @Test + @DisplayName("Cannot claim the tokens with duplicate entries") + void cannotClaimTokensWithDuplicateEntries() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var record = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // claim the tokens with duplicate pending airdrop token ids + // fails with PENDING_AIRDROP_ID_REPEATED + assertThatExceptionOfType(PrecheckStatusException.class).isThrownBy(() -> { + new TokenClaimAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .execute(testEnv.client) + .getRecord(testEnv.client); + }).withMessageContaining(Status.PENDING_AIRDROP_ID_REPEATED.toString()); + + testEnv.close(); + } + + @Test + @DisplayName("Cannot claim the tokens when token is paused") + void cannotClaimTokensWhenTokenIsPaused() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var record = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // pause the token + new TokenPauseTransaction().setTokenId(tokenID).execute(testEnv.client).getReceipt(testEnv.client); + + // claim the tokens with receiver + // fails with TOKEN_IS_PAUSED + assertThatExceptionOfType(ReceiptStatusException.class).isThrownBy(() -> { + new TokenClaimAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .freezeWith(testEnv.client) + .sign(receiverAccountKey) + .execute(testEnv.client) + .getRecord(testEnv.client); + }).withMessageContaining(Status.TOKEN_IS_PAUSED.toString()); + + testEnv.close(); + } + + @Test + @DisplayName("Cannot claim the tokens when token is deleted") + void cannotClaimTokensWhenTokenIsDeleted() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var record = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // delete the token + new TokenDeleteTransaction().setTokenId(tokenID).execute(testEnv.client).getReceipt(testEnv.client); + + // claim the tokens with receiver + // fails with TOKEN_IS_DELETED + assertThatExceptionOfType(ReceiptStatusException.class).isThrownBy(() -> { + new TokenClaimAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .freezeWith(testEnv.client) + .sign(receiverAccountKey) + .execute(testEnv.client) + .getRecord(testEnv.client); + }).withMessageContaining(Status.TOKEN_WAS_DELETED.toString()); + + testEnv.close(); + } + + @Test + @DisplayName("Cannot claim the tokens when token is frozen") + void cannotClaimTokensWhenTokenIsFrozen() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // create receiver with 0 auto associations + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var record = new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getRecord(testEnv.client); + + // associate + new TokenAssociateTransaction() + .setAccountId(receiverAccountId) + .setTokenIds(Collections.singletonList(tokenID)) + .freezeWith(testEnv.client) + .sign(receiverAccountKey) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + // freeze the token + new TokenFreezeTransaction() + .setAccountId(receiverAccountId) + .setTokenId(tokenID) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + // claim the tokens with receiver + // fails with ACCOUNT_FROZEN_FOR_TOKEN + assertThatExceptionOfType(ReceiptStatusException.class).isThrownBy(() -> { + new TokenClaimAirdropTransaction() + .addPendingAirdrop(record.pendingAirdropRecords.get(0).getPendingAirdropId()) + .freezeWith(testEnv.client) + .sign(receiverAccountKey) + .execute(testEnv.client) + .getRecord(testEnv.client); + }).withMessageContaining(Status.ACCOUNT_FROZEN_FOR_TOKEN.toString()); + + testEnv.close(); + } +}