From fdd2bb89bf8b53bd2399c7c14d4ca15e0b912a2f Mon Sep 17 00:00:00 2001 From: Ivan Ivanov Date: Mon, 9 Sep 2024 14:40:22 +0300 Subject: [PATCH] feat(HIP-904) - TokenAirdropTransaction (#1966) Signed-off-by: Ivan Ivanov --- .../sdk/AbstractTokenTransferTransaction.java | 386 ++++++++++++++ .../hashgraph/sdk/PendingAirdropId.java | 127 +++++ .../hashgraph/sdk/PendingAirdropRecord.java | 62 +++ .../sdk/TokenAirdropTransaction.java | 130 +++++ .../hashgraph/sdk/TokenTransferList.java | 73 +++ .../com/hedera/hashgraph/sdk/Transaction.java | 2 + .../hashgraph/sdk/TransactionRecord.java | 31 +- .../hashgraph/sdk/TransferTransaction.java | 428 ++------------- .../hashgraph/sdk/PendingAirdropIdTest.java | 163 ++++++ .../sdk/TokenAirdropTransactionTest.java | 216 ++++++++ .../sdk/TokenAirdropTransactionTest.snap | 3 + .../hashgraph/sdk/TransactionRecordTest.java | 6 +- .../hashgraph/sdk/TransactionRecordTest.snap | 4 +- .../sdk/test/integration/EntityHelper.java | 9 +- ...okenAirdropTransactionIntegrationTest.java | 493 ++++++++++++++++++ 15 files changed, 1729 insertions(+), 404 deletions(-) create mode 100644 sdk/src/main/java/com/hedera/hashgraph/sdk/AbstractTokenTransferTransaction.java create mode 100644 sdk/src/main/java/com/hedera/hashgraph/sdk/PendingAirdropId.java create mode 100644 sdk/src/main/java/com/hedera/hashgraph/sdk/PendingAirdropRecord.java create mode 100644 sdk/src/main/java/com/hedera/hashgraph/sdk/TokenAirdropTransaction.java create mode 100644 sdk/src/main/java/com/hedera/hashgraph/sdk/TokenTransferList.java create mode 100644 sdk/src/test/java/com/hedera/hashgraph/sdk/PendingAirdropIdTest.java create mode 100644 sdk/src/test/java/com/hedera/hashgraph/sdk/TokenAirdropTransactionTest.java create mode 100644 sdk/src/test/java/com/hedera/hashgraph/sdk/TokenAirdropTransactionTest.snap create mode 100644 sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/TokenAirdropTransactionIntegrationTest.java diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/AbstractTokenTransferTransaction.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/AbstractTokenTransferTransaction.java new file mode 100644 index 000000000..3b090d477 --- /dev/null +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/AbstractTokenTransferTransaction.java @@ -0,0 +1,386 @@ +/*- + * + * 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.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +abstract class AbstractTokenTransferTransaction> extends Transaction { + + protected final ArrayList tokenTransfers = new ArrayList<>(); + protected final ArrayList nftTransfers = new ArrayList<>(); + + protected AbstractTokenTransferTransaction() {} + + /** + * Constructor. + * + * @param txs Compound list of transaction id's list of (AccountId, Transaction) records + * @throws InvalidProtocolBufferException when there is an issue with the protobuf + */ + AbstractTokenTransferTransaction( + LinkedHashMap> txs) + throws InvalidProtocolBufferException { + super(txs); + } + + /** + * Constructor. + * + * @param txBody protobuf TransactionBody + */ + AbstractTokenTransferTransaction(com.hedera.hashgraph.sdk.proto.TransactionBody txBody) { + super(txBody); + } + + + /** + * Extract the list of token id decimals. + * + * @return the list of token id decimals + */ + public Map getTokenIdDecimals() { + Map decimalsMap = new HashMap<>(); + + for (var transfer : tokenTransfers) { + decimalsMap.put(transfer.tokenId, transfer.expectedDecimals); + } + + return decimalsMap; + } + + /** + * Extract the list of token transfer records. + * + * @return the list of token transfer records + */ + public Map> getTokenTransfers() { + Map> transfers = new HashMap<>(); + + for (var transfer : tokenTransfers) { + var current = transfers.get(transfer.tokenId) != null + ? transfers.get(transfer.tokenId) : new HashMap(); + current.put(transfer.accountId, transfer.amount); + transfers.put(transfer.tokenId, current); + } + + return transfers; + } + + private T doAddTokenTransfer(TokenId tokenId, AccountId accountId, long value, + boolean isApproved) { + requireNotFrozen(); + + for (var transfer : tokenTransfers) { + if (transfer.tokenId.equals(tokenId) && transfer.accountId.equals(accountId) + && transfer.isApproved == isApproved) { + transfer.amount = transfer.amount + value; + // noinspection unchecked + return (T) this; + } + } + + tokenTransfers.add(new TokenTransfer(tokenId, accountId, value, isApproved)); + // noinspection unchecked + return (T) this; + } + + /** + * Add a non-approved token transfer to the transaction. + * + * @param tokenId the token id + * @param accountId the account id + * @param value the value + * @return the updated transaction + */ + public T addTokenTransfer(TokenId tokenId, AccountId accountId, long value) { + return doAddTokenTransfer(tokenId, accountId, value, false); + } + + /** + * Add an approved token transfer to the transaction. + * + * @param tokenId the token id + * @param accountId the account id + * @param value the value + * @return the updated transaction + */ + public T addApprovedTokenTransfer(TokenId tokenId, AccountId accountId, long value) { + return doAddTokenTransfer(tokenId, accountId, value, true); + } + + private T doAddTokenTransferWithDecimals( + TokenId tokenId, + AccountId accountId, + long value, + int decimals, + boolean isApproved + ) { + requireNotFrozen(); + + var found = false; + + for (var transfer : tokenTransfers) { + if (transfer.tokenId.equals(tokenId)) { + if (transfer.expectedDecimals != null && transfer.expectedDecimals != decimals) { + throw new IllegalArgumentException( + "expected decimals for a token in a token transfer cannot be changed after being set"); + } + + transfer.expectedDecimals = decimals; + + if (transfer.accountId.equals(accountId) && transfer.isApproved == isApproved) { + transfer.amount = transfer.amount + value; + found = true; + } + + } + } + + if (found) { + // noinspection unchecked + return (T) this; + } + tokenTransfers.add(new TokenTransfer(tokenId, accountId, value, decimals, isApproved)); + + // noinspection unchecked + return (T) this; + } + + /** + * Add a non-approved token transfer with decimals. + * + * @param tokenId the token id + * @param accountId the account id + * @param value the value + * @param decimals the decimals + * @return the updated transaction + */ + public T addTokenTransferWithDecimals( + TokenId tokenId, + AccountId accountId, + long value, + int decimals + ) { + return doAddTokenTransferWithDecimals(tokenId, accountId, value, decimals, false); + } + + /** + * Add an approved token transfer with decimals. + * + * @param tokenId the token id + * @param accountId the account id + * @param value the value + * @param decimals the decimals + * @return the updated transaction + */ + public T addApprovedTokenTransferWithDecimals( + TokenId tokenId, + AccountId accountId, + long value, + int decimals + ) { + return doAddTokenTransferWithDecimals(tokenId, accountId, value, decimals, true); + } + + /** + * @param tokenId the token id + * @param accountId the account id + * @param isApproved whether the transfer is approved + * @return {@code this} + * @deprecated - Use {@link #addApprovedTokenTransfer(TokenId, AccountId, long)} instead + */ + @Deprecated + public T setTokenTransferApproval(TokenId tokenId, AccountId accountId, boolean isApproved) { + requireNotFrozen(); + + for (var transfer : tokenTransfers) { + if (transfer.tokenId.equals(tokenId) && transfer.accountId.equals(accountId)) { + transfer.isApproved = isApproved; + // noinspection unchecked + return (T) this; + } + } + + // noinspection unchecked + return (T) this; + } + + /** + * Extract the of token nft transfers. + * + * @return list of token nft transfers + */ + public Map> getTokenNftTransfers() { + Map> transfers = new HashMap<>(); + + for (var transfer : nftTransfers) { + var current = transfers.get(transfer.tokenId) != null + ? transfers.get(transfer.tokenId) : new ArrayList(); + current.add(transfer); + transfers.put(transfer.tokenId, current); + } + + return transfers; + } + + private T doAddNftTransfer(NftId nftId, AccountId sender, AccountId receiver, + boolean isApproved) { + requireNotFrozen(); + nftTransfers.add(new TokenNftTransfer(nftId.tokenId, sender, receiver, nftId.serial, isApproved)); + return (T) this; + } + + /** + * Add a non-approved nft transfer. + * + * @param nftId the nft's id + * @param sender the sender account id + * @param receiver the receiver account id + * @return the updated transaction + */ + public T addNftTransfer(NftId nftId, AccountId sender, AccountId receiver) { + return doAddNftTransfer(nftId, sender, receiver, false); + } + + /** + * Add an approved nft transfer. + * + * @param nftId the nft's id + * @param sender the sender account id + * @param receiver the receiver account id + * @return the updated transaction + */ + public T addApprovedNftTransfer(NftId nftId, AccountId sender, AccountId receiver) { + return doAddNftTransfer(nftId, sender, receiver, true); + } + + /** + * @param nftId the NFT id + * @param isApproved whether the transfer is approved + * @return {@code this} + * @deprecated - Use {@link #addApprovedNftTransfer(NftId, AccountId, AccountId)} instead + */ + @Deprecated + public T setNftTransferApproval(NftId nftId, boolean isApproved) { + requireNotFrozen(); + + for (var transfer : nftTransfers) { + if (transfer.tokenId.equals(nftId.tokenId) && transfer.serial == nftId.serial) { + transfer.isApproved = isApproved; + // noinspection unchecked + return (T) this; + } + } + + // noinspection unchecked + return (T) this; + } + + protected ArrayList sortTransfersAndBuild() { + var transferLists = new ArrayList(); + + this.tokenTransfers.sort(Comparator.comparing((TokenTransfer a) -> a.tokenId).thenComparing(a -> a.accountId) + .thenComparing(a -> a.isApproved)); + this.nftTransfers.sort(Comparator.comparing((TokenNftTransfer a) -> a.tokenId).thenComparing(a -> a.sender) + .thenComparing(a -> a.receiver).thenComparing(a -> a.serial)); + + var i = 0; + var j = 0; + + // Effectively merge sort + while (i < this.tokenTransfers.size() || j < this.nftTransfers.size()) { + if (i < this.tokenTransfers.size() && j < this.nftTransfers.size()) { + var iTokenId = this.tokenTransfers.get(i).tokenId; + var jTokenId = this.nftTransfers.get(j).tokenId; + var last = !transferLists.isEmpty() ? transferLists.get(transferLists.size() - 1) : null; + var lastTokenId = last != null ? last.tokenId : null; + + if (last != null && iTokenId.compareTo(lastTokenId) == 0) { + last.transfers.add(this.tokenTransfers.get(i++)); + continue; + } + + if (last != null && jTokenId.compareTo(lastTokenId) == 0) { + last.nftTransfers.add(this.nftTransfers.get(j++)); + continue; + } + + var result = iTokenId.compareTo(jTokenId); + + if (result == 0) { + transferLists.add(new com.hedera.hashgraph.sdk.TokenTransferList(iTokenId, + this.tokenTransfers.get(i).expectedDecimals, this.tokenTransfers.get(i++), + this.nftTransfers.get(j++))); + } else if (result < 0) { + transferLists.add(new com.hedera.hashgraph.sdk.TokenTransferList(iTokenId, + this.tokenTransfers.get(i).expectedDecimals, this.tokenTransfers.get(i++), null)); + } else { + transferLists.add(new com.hedera.hashgraph.sdk.TokenTransferList(jTokenId, null, null, + this.nftTransfers.get(j++))); + } + } else if (i < this.tokenTransfers.size()) { + var iTokenId = this.tokenTransfers.get(i).tokenId; + var last = !transferLists.isEmpty() ? transferLists.get(transferLists.size() - 1) : null; + var lastTokenId = last != null ? last.tokenId : null; + + if (last != null && iTokenId.compareTo(lastTokenId) == 0) { + last.transfers.add(this.tokenTransfers.get(i++)); + continue; + } + + transferLists.add(new com.hedera.hashgraph.sdk.TokenTransferList(iTokenId, + this.tokenTransfers.get(i).expectedDecimals, this.tokenTransfers.get(i++), null)); + } else { + var jTokenId = this.nftTransfers.get(j).tokenId; + var last = !transferLists.isEmpty() ? transferLists.get(transferLists.size() - 1) : null; + var lastTokenId = last != null ? last.tokenId : null; + + if (last != null && jTokenId.compareTo(lastTokenId) == 0) { + last.nftTransfers.add(this.nftTransfers.get(j++)); + continue; + } + + transferLists.add( + new com.hedera.hashgraph.sdk.TokenTransferList(jTokenId, null, null, this.nftTransfers.get(j++))); + } + } + return transferLists; + } + + @Override + void validateChecksums(Client client) throws BadEntityIdException { + for (var transfer : nftTransfers) { + transfer.tokenId.validateChecksum(client); + transfer.sender.validateChecksum(client); + transfer.receiver.validateChecksum(client); + } + + for (var transfer : tokenTransfers) { + transfer.tokenId.validateChecksum(client); + transfer.accountId.validateChecksum(client); + } + } +} diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/PendingAirdropId.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/PendingAirdropId.java new file mode 100644 index 000000000..2a826289d --- /dev/null +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/PendingAirdropId.java @@ -0,0 +1,127 @@ +/*- + * + * 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.common.base.MoreObjects; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * A unique, composite, identifier for a pending airdrop. + * + * Each pending airdrop SHALL be uniquely identified by a PendingAirdropId. + * A PendingAirdropId SHALL be recorded when created and MUST be provided in any transaction + * that would modify that pending airdrop (such as a `claimAirdrop` or `cancelAirdrop`). + */ +public class PendingAirdropId { + private AccountId sender; + private AccountId receiver; + @Nullable + private TokenId tokenId; + @Nullable + private NftId nftId; + + public PendingAirdropId() {} + + PendingAirdropId(AccountId sender, AccountId receiver, TokenId tokenId) { + this.sender = sender; + this.receiver = receiver; + this.tokenId = tokenId; + this.nftId = null; + } + + PendingAirdropId(AccountId sender, AccountId receiver, NftId nftId) { + this.sender = sender; + this.receiver = receiver; + this.nftId = nftId; + this.tokenId = null; + } + + public AccountId getSender() { + return sender; + } + + public PendingAirdropId setSender(@Nonnull AccountId sender) { + this.sender = sender; + return this; + } + + public AccountId getReceiver() { + return receiver; + } + + public PendingAirdropId setReceiver(@Nonnull AccountId receiver) { + this.receiver = receiver; + return this; + } + + public TokenId getTokenId() { + return tokenId; + } + + public PendingAirdropId setTokenId(@Nullable TokenId tokenId) { + this.tokenId = tokenId; + return this; + } + + public NftId getNftId() { + return nftId; + } + + public PendingAirdropId setNftId(@Nullable NftId nftId) { + this.nftId = nftId; + return this; + } + + static PendingAirdropId fromProtobuf(com.hedera.hashgraph.sdk.proto.PendingAirdropId pendingAirdropId) { + if (pendingAirdropId.hasFungibleTokenType()) { + return new PendingAirdropId(AccountId.fromProtobuf(pendingAirdropId.getSenderId()), + AccountId.fromProtobuf(pendingAirdropId.getReceiverId()), + TokenId.fromProtobuf(pendingAirdropId.getFungibleTokenType())); + } else { + return new PendingAirdropId(AccountId.fromProtobuf(pendingAirdropId.getSenderId()), + AccountId.fromProtobuf(pendingAirdropId.getReceiverId()), + NftId.fromProtobuf(pendingAirdropId.getNonFungibleToken())); + } + } + + com.hedera.hashgraph.sdk.proto.PendingAirdropId toProtobuf() { + var builder = com.hedera.hashgraph.sdk.proto.PendingAirdropId.newBuilder() + .setSenderId(sender.toProtobuf()) + .setReceiverId(receiver.toProtobuf()); + + if (tokenId != null) { + builder.setFungibleTokenType(tokenId.toProtobuf()); + } else if (nftId != null) { + builder.setNonFungibleToken(nftId.toProtobuf()); + } + return builder.build(); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("sender", sender) + .add("receiver", receiver) + .add("tokenId", tokenId) + .add("nftId", nftId) + .toString(); + } +} diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/PendingAirdropRecord.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/PendingAirdropRecord.java new file mode 100644 index 000000000..ebff20ab9 --- /dev/null +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/PendingAirdropRecord.java @@ -0,0 +1,62 @@ +/*- + * + * 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.common.base.MoreObjects; +import com.hedera.hashgraph.sdk.proto.PendingAirdropValue; + +public class PendingAirdropRecord { + private final PendingAirdropId pendingAirdropId; + private final long pendingAirdropAmount; + + PendingAirdropRecord(PendingAirdropId pendingAirdropId, long pendingAirdropAmount) { + this.pendingAirdropId = pendingAirdropId; + this.pendingAirdropAmount = pendingAirdropAmount; + } + + public PendingAirdropId getPendingAirdropId() { + return pendingAirdropId; + } + + public long getPendingAirdropAmount() { + return pendingAirdropAmount; + } + + com.hedera.hashgraph.sdk.proto.PendingAirdropRecord toProtobuf() { + return com.hedera.hashgraph.sdk.proto.PendingAirdropRecord.newBuilder() + .setPendingAirdropId(this.pendingAirdropId.toProtobuf()) + .setPendingAirdropValue(PendingAirdropValue.newBuilder().setAmount(pendingAirdropAmount)) + .build(); + } + + static PendingAirdropRecord fromProtobuf(com.hedera.hashgraph.sdk.proto.PendingAirdropRecord pendingAirdropRecord) { + return new PendingAirdropRecord( + PendingAirdropId.fromProtobuf(pendingAirdropRecord.getPendingAirdropId()), + pendingAirdropRecord.getPendingAirdropValue().getAmount()); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("pendingAirdropId", pendingAirdropId) + .add("pendingAirdropAmount", pendingAirdropAmount) + .toString(); + } +} diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/TokenAirdropTransaction.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/TokenAirdropTransaction.java new file mode 100644 index 000000000..0fe56f323 --- /dev/null +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/TokenAirdropTransaction.java @@ -0,0 +1,130 @@ +/*- + * + * 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.TokenAirdropTransactionBody; +import com.hedera.hashgraph.sdk.proto.TokenServiceGrpc; +import com.hedera.hashgraph.sdk.proto.TransactionBody; +import com.hedera.hashgraph.sdk.proto.TransactionResponse; +import io.grpc.MethodDescriptor; +import java.util.LinkedHashMap; + +/** + * Token Airdrop + * An "airdrop" is a distribution of tokens from a funding account + * to one or more recipient accounts, ideally with no action required + * by the recipient account(s). + */ +public class TokenAirdropTransaction extends AbstractTokenTransferTransaction { + /** + * Constructor. + */ + public TokenAirdropTransaction() { + super(); + defaultMaxTransactionFee = new Hbar(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 + */ + TokenAirdropTransaction(LinkedHashMap> txs) throws InvalidProtocolBufferException { + super(txs); + initFromTransactionBody(); + } + + /** + * Constructor. + * + * @param txBody protobuf TransactionBody + */ + TokenAirdropTransaction(com.hedera.hashgraph.sdk.proto.TransactionBody txBody) { + super(txBody); + initFromTransactionBody(); + } + + /** + * Build the transaction body. + * + * @return {@link + * com.hedera.hashgraph.sdk.proto.TokenAirdropTransactionBody} + */ + TokenAirdropTransactionBody.Builder build() { + var transfers = sortTransfersAndBuild(); + var builder = TokenAirdropTransactionBody.newBuilder(); + + for (var transfer : transfers) { + builder.addTokenTransfers(transfer.toProtobuf()); + } + + return builder; + } + + @Override + MethodDescriptor getMethodDescriptor() { + return TokenServiceGrpc.getAirdropTokensMethod(); + } + + @Override + void onFreeze(TransactionBody.Builder bodyBuilder) { + bodyBuilder.setTokenAirdrop(build()); + } + + @Override + void onScheduled(SchedulableTransactionBody.Builder scheduled) { + scheduled.setTokenAirdrop(build()); + } + + /** + * Initialize from the transaction body. + */ + void initFromTransactionBody() { + var body = sourceTransactionBody.getTokenAirdrop(); + + for (var tokenTransferList : body.getTokenTransfersList()) { + var token = TokenId.fromProtobuf(tokenTransferList.getToken()); + + for (var transfer : tokenTransferList.getTransfersList()) { + tokenTransfers.add(new TokenTransfer( + token, + AccountId.fromProtobuf(transfer.getAccountID()), + transfer.getAmount(), + tokenTransferList.hasExpectedDecimals() ? tokenTransferList.getExpectedDecimals().getValue() : null, + transfer.getIsApproval() + )); + } + + for (var transfer : tokenTransferList.getNftTransfersList()) { + nftTransfers.add(new TokenNftTransfer( + token, + AccountId.fromProtobuf(transfer.getSenderAccountID()), + AccountId.fromProtobuf(transfer.getReceiverAccountID()), + transfer.getSerialNumber(), + transfer.getIsApproval() + )); + } + } + } +} diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/TokenTransferList.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/TokenTransferList.java new file mode 100644 index 000000000..3d45b41ac --- /dev/null +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/TokenTransferList.java @@ -0,0 +1,73 @@ +/*- + * + * 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.UInt32Value; +import com.hedera.hashgraph.sdk.proto.AccountAmount; +import com.hedera.hashgraph.sdk.proto.NftTransfer; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; + +class TokenTransferList { + final TokenId tokenId; + @Nullable + final Integer expectDecimals; + List transfers = new ArrayList<>(); + List nftTransfers = new ArrayList<>(); + + TokenTransferList(TokenId tokenId, @Nullable Integer expectDecimals, @Nullable TokenTransfer transfer, + @Nullable TokenNftTransfer nftTransfer) { + this.tokenId = tokenId; + this.expectDecimals = expectDecimals; + + if (transfer != null) { + this.transfers.add(transfer); + } + + if (nftTransfer != null) { + this.nftTransfers.add(nftTransfer); + } + } + + com.hedera.hashgraph.sdk.proto.TokenTransferList toProtobuf() { + var transfers = new ArrayList(); + var nftTransfers = new ArrayList(); + + for (var transfer : this.transfers) { + transfers.add(transfer.toProtobuf()); + } + + for (var transfer : this.nftTransfers) { + nftTransfers.add(transfer.toProtobuf()); + } + + var builder = com.hedera.hashgraph.sdk.proto.TokenTransferList.newBuilder() + .setToken(tokenId.toProtobuf()) + .addAllTransfers(transfers) + .addAllNftTransfers(nftTransfers); + + if (expectDecimals != null) { + builder.setExpectedDecimals(UInt32Value.newBuilder().setValue(expectDecimals).build()); + } + + return builder.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 7fecf87f4..74fc7ecf0 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/Transaction.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/Transaction.java @@ -358,6 +358,7 @@ public static Transaction fromBytes(byte[] bytes) throws InvalidProtocolBuffe case TOKEN_PAUSE -> new TokenPauseTransaction(txs); case TOKEN_UNPAUSE -> new TokenUnpauseTransaction(txs); case TOKENREJECT -> new TokenRejectTransaction(txs); + case TOKENAIRDROP -> new TokenAirdropTransaction(txs); case CRYPTOAPPROVEALLOWANCE -> new AccountAllowanceApproveTransaction(txs); case CRYPTODELETEALLOWANCE -> new AccountAllowanceDeleteTransaction(txs); default -> throw new IllegalArgumentException("parsed transaction body has no data"); @@ -443,6 +444,7 @@ public static Transaction fromScheduledTransaction( new TokenUnpauseTransaction(body.setTokenUnpause(scheduled.getTokenUnpause()).build()); case TOKENREJECT -> new TokenRejectTransaction(body.setTokenReject(scheduled.getTokenReject()).build()); + case TOKENAIRDROP -> new TokenAirdropTransaction(body.setTokenAirdrop(scheduled.getTokenAirdrop()).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/java/com/hedera/hashgraph/sdk/TransactionRecord.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/TransactionRecord.java index be72ec5ba..956067360 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/TransactionRecord.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/TransactionRecord.java @@ -197,6 +197,19 @@ public final class TransactionRecord { */ public final ByteString evmAddress; + /** + * A list of pending token airdrops. + * Each pending airdrop represents a single requested transfer from a + * sending account to a recipient account. These pending transfers are + * issued unilaterally by the sending account, and MUST be claimed by the + * recipient account before the transfer MAY complete. + * A sender MAY cancel a pending airdrop before it is claimed. + * An airdrop transaction SHALL emit a pending airdrop when the recipient has no + * available automatic association slots available or when the recipient + * has set `receiver_sig_required`. + */ + public final List pendingAirdropRecords; + TransactionRecord( TransactionReceipt transactionReceipt, ByteString transactionHash, @@ -220,7 +233,8 @@ public final class TransactionRecord { List paidStakingRewards, @Nullable ByteString prngBytes, @Nullable Integer prngNumber, - ByteString evmAddress + ByteString evmAddress, + List pendingAirdropRecords ) { this.receipt = transactionReceipt; this.transactionHash = transactionHash; @@ -241,6 +255,7 @@ public final class TransactionRecord { this.duplicates = duplicates; this.parentConsensusTimestamp = parentConsensusTimestamp; this.ethereumHash = ethereumHash; + this.pendingAirdropRecords = pendingAirdropRecords; this.hbarAllowanceAdjustments = Collections.emptyList(); this.tokenAllowanceAdjustments = Collections.emptyList(); this.tokenNftAllowanceAdjustments = Collections.emptyList(); @@ -311,6 +326,10 @@ static TransactionRecord fromProtobuf( paidStakingRewards.add(Transfer.fromProtobuf(reward)); } + List pendingAirdropRecords = transactionRecord.getNewPendingAirdropsList() + .stream().map(PendingAirdropRecord::fromProtobuf) + .toList(); + return new TransactionRecord( TransactionReceipt.fromProtobuf(transactionRecord.getReceipt(), transactionId), transactionRecord.getTransactionHash(), @@ -335,7 +354,8 @@ static TransactionRecord fromProtobuf( paidStakingRewards, transactionRecord.hasPrngBytes() ? transactionRecord.getPrngBytes() : null, transactionRecord.hasPrngNumber() ? transactionRecord.getPrngNumber() : null, - transactionRecord.getEvmAddress() + transactionRecord.getEvmAddress(), + pendingAirdropRecords ); } @@ -460,6 +480,12 @@ com.hedera.hashgraph.sdk.proto.TransactionRecord toProtobuf() { transactionRecord.setPrngNumber(prngNumber); } + if (pendingAirdropRecords != null) { + for (PendingAirdropRecord pendingAirdropRecord : pendingAirdropRecords) { + transactionRecord.addNewPendingAirdrops(pendingAirdropRecords.indexOf(pendingAirdropRecord), pendingAirdropRecord.toProtobuf()); + } + } + return transactionRecord.build(); } @@ -488,6 +514,7 @@ public String toString() { .add("prngBytes", prngBytes != null ? Hex.toHexString(prngBytes.toByteArray()) : null) .add("prngNumber", prngNumber) .add("evmAddress", Hex.toHexString(evmAddress.toByteArray())) + .add("pendingAirdropRecords", pendingAirdropRecords.toString()) .toString(); } diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/TransferTransaction.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/TransferTransaction.java index fba7d1f71..25bac22f2 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/TransferTransaction.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/TransferTransaction.java @@ -21,36 +21,28 @@ import com.google.common.base.MoreObjects; import com.google.protobuf.InvalidProtocolBufferException; -import com.google.protobuf.UInt32Value; import com.hedera.hashgraph.sdk.proto.AccountAmount; import com.hedera.hashgraph.sdk.proto.CryptoServiceGrpc; import com.hedera.hashgraph.sdk.proto.CryptoTransferTransactionBody; -import com.hedera.hashgraph.sdk.proto.NftTransfer; import com.hedera.hashgraph.sdk.proto.SchedulableTransactionBody; import com.hedera.hashgraph.sdk.proto.TransactionBody; import com.hedera.hashgraph.sdk.proto.TransactionResponse; import com.hedera.hashgraph.sdk.proto.TransferList; import io.grpc.MethodDescriptor; - -import javax.annotation.Nullable; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; /** - * A transaction that transfers hbars and tokens between Hedera accounts. - * You can enter multiple transfers in a single transaction. The net value - * of hbars between the sending accounts and receiving accounts must equal - * zero. - * - * See Hedera Documentation + * A transaction that transfers hbars and tokens between Hedera accounts. You can enter multiple transfers in a single + * transaction. The net value of hbars between the sending accounts and receiving accounts must equal zero. + *

+ * See Hedera + * Documentation */ -public class TransferTransaction extends Transaction { - private final ArrayList tokenTransfers = new ArrayList<>(); - private final ArrayList nftTransfers = new ArrayList<>(); +public class TransferTransaction extends AbstractTokenTransferTransaction { private final ArrayList hbarTransfers = new ArrayList<>(); private static class HbarTransfer { @@ -82,53 +74,6 @@ public String toString() { } } - private static class TokenTransferList { - final TokenId tokenId; - - @Nullable - final Integer expectDecimals; - - List transfers = new ArrayList<>(); - List nftTransfers = new ArrayList<>(); - - TokenTransferList(TokenId tokenId, @Nullable Integer expectDecimals, @Nullable TokenTransfer transfer, @Nullable TokenNftTransfer nftTransfer) { - this.tokenId = tokenId; - this.expectDecimals = expectDecimals; - - if (transfer != null) { - this.transfers.add(transfer); - } - - if (nftTransfer != null) { - this.nftTransfers.add(nftTransfer); - } - } - - com.hedera.hashgraph.sdk.proto.TokenTransferList toProtobuf() { - var transfers = new ArrayList(); - var nftTransfers = new ArrayList(); - - for (var transfer : this.transfers) { - transfers.add(transfer.toProtobuf()); - } - - for (var transfer : this.nftTransfers) { - nftTransfers.add(transfer.toProtobuf()); - } - - var builder = com.hedera.hashgraph.sdk.proto.TokenTransferList.newBuilder() - .setToken(tokenId.toProtobuf()) - .addAllTransfers(transfers) - .addAllNftTransfers(nftTransfers); - - if (expectDecimals != null) { - builder.setExpectedDecimals(UInt32Value.newBuilder().setValue(expectDecimals).build()); - } - - return builder.build(); - } - } - /** * Constructor. */ @@ -139,11 +84,12 @@ public TransferTransaction() { /** * Constructor. * - * @param txs Compound list of transaction id's list of (AccountId, Transaction) - * records - * @throws InvalidProtocolBufferException when there is an issue with the protobuf + * @param txs Compound list of transaction id's list of (AccountId, Transaction) records + * @throws InvalidProtocolBufferException when there is an issue with the protobuf */ - TransferTransaction(LinkedHashMap> txs) throws InvalidProtocolBufferException { + TransferTransaction( + LinkedHashMap> txs) + throws InvalidProtocolBufferException { super(txs); initFromTransactionBody(); } @@ -158,251 +104,10 @@ public TransferTransaction() { initFromTransactionBody(); } - /** - * Extract the list of token id decimals. - * - * @return the list of token id decimals - */ - public Map getTokenIdDecimals() { - Map decimalsMap = new HashMap<>(); - - for (var transfer : tokenTransfers) { - decimalsMap.put(transfer.tokenId, transfer.expectedDecimals); - } - - return decimalsMap; - } - - /** - * Extract the list of token transfer records. - * - * @return the list of token transfer records - */ - public Map> getTokenTransfers() { - Map> transfers = new HashMap<>(); - - for (var transfer : tokenTransfers) { - var current = transfers.get(transfer.tokenId) != null - ? transfers.get(transfer.tokenId) : new HashMap(); - current.put(transfer.accountId, transfer.amount); - transfers.put(transfer.tokenId, current); - } - - return transfers; - } - - /** - * Add a token transfer to the transaction. - * - * @param tokenId the token id - * @param accountId the account id - * @param value the value - * @param isApproved is it approved - * @return {@code this} - */ - private TransferTransaction doAddTokenTransfer(TokenId tokenId, AccountId accountId, long value, boolean isApproved) { - requireNotFrozen(); - - for (var transfer : tokenTransfers) { - if (transfer.tokenId.equals(tokenId) && transfer.accountId.equals(accountId) && transfer.isApproved == isApproved) { - transfer.amount = transfer.amount + value; - return this; - } - } - - tokenTransfers.add(new TokenTransfer(tokenId, accountId, value, isApproved)); - return this; - } - - /** - * Add a non approved token transfer to the transaction. - * - * @param tokenId the token id - * @param accountId the account id - * @param value the value - * @return the updated transaction - */ - public TransferTransaction addTokenTransfer(TokenId tokenId, AccountId accountId, long value) { - return doAddTokenTransfer(tokenId, accountId, value, false); - } - - /** - * Add an approved token transfer to the transaction. - * - * @param tokenId the token id - * @param accountId the account id - * @param value the value - * @return the updated transaction - */ - public TransferTransaction addApprovedTokenTransfer(TokenId tokenId, AccountId accountId, long value) { - return doAddTokenTransfer(tokenId, accountId, value, true); - } - - private TransferTransaction doAddTokenTransferWithDecimals( - TokenId tokenId, - AccountId accountId, - long value, - int decimals, - boolean isApproved - ) { - requireNotFrozen(); - - var found = false; - - for (var transfer : tokenTransfers) { - if (transfer.tokenId.equals(tokenId)) { - if (transfer.expectedDecimals != null && transfer.expectedDecimals != decimals) { - throw new IllegalArgumentException("expected decimals for a token in a token transfer cannot be changed after being set"); - } - - transfer.expectedDecimals = decimals; - - if (transfer.accountId.equals(accountId) && transfer.isApproved == isApproved) { - transfer.amount = transfer.amount + value; - found = true; - } - - } - } - - if (found) { - return this; - } - - tokenTransfers.add(new TokenTransfer(tokenId, accountId, value, decimals, isApproved)); - - return this; - } - - /** - * Add a non approved token transfer with decimals. - * - * @param tokenId the token id - * @param accountId the account id - * @param value the value - * @param decimals the deco - * @return the updated transaction - */ - public TransferTransaction addTokenTransferWithDecimals( - TokenId tokenId, - AccountId accountId, - long value, - int decimals - ) { - return doAddTokenTransferWithDecimals(tokenId, accountId, value, decimals, false); - } - - /** - * Add an approved token transfer with decimals. - * - * @param tokenId the token id - * @param accountId the account id - * @param value the value - * @param decimals the deco - * @return the updated transaction - */ - public TransferTransaction addApprovedTokenTransferWithDecimals( - TokenId tokenId, - AccountId accountId, - long value, - int decimals - ) { - return doAddTokenTransferWithDecimals(tokenId, accountId, value, decimals, true); - } - - /** - * @deprecated - Use {@link #addApprovedTokenTransfer(TokenId, AccountId, long)} instead - * @param tokenId the token id - * @param accountId the account id - * @param isApproved whether the transfer is approved - * @return {@code this} - */ - @Deprecated - public TransferTransaction setTokenTransferApproval(TokenId tokenId, AccountId accountId, boolean isApproved) { - requireNotFrozen(); - - for (var transfer : tokenTransfers) { - if (transfer.tokenId.equals(tokenId) && transfer.accountId.equals(accountId)) { - transfer.isApproved = isApproved; - return this; - } - } - - return this; - } - - /** - * Extract the of token nft transfers. - * - * @return list of token nft transfers - */ - public Map> getTokenNftTransfers() { - Map> transfers = new HashMap<>(); - - for (var transfer : nftTransfers) { - var current = transfers.get(transfer.tokenId) != null - ? transfers.get(transfer.tokenId) : new ArrayList(); - current.add(transfer); - transfers.put(transfer.tokenId, current); - } - - return transfers; - } - - private TransferTransaction doAddNftTransfer(NftId nftId, AccountId sender, AccountId receiver, boolean isApproved) { - requireNotFrozen(); - nftTransfers.add(new TokenNftTransfer(nftId.tokenId, sender, receiver, nftId.serial, isApproved)); - return this; - } - - /** - * Add a non approved nft transfer. - * - * @param nftId the nft's id - * @param sender the sender account id - * @param receiver the receiver account id - * @return the updated transaction - */ - public TransferTransaction addNftTransfer(NftId nftId, AccountId sender, AccountId receiver) { - return doAddNftTransfer(nftId, sender, receiver, false); - } - - /** - * Add an approved nft transfer. - * - * @param nftId the nft's id - * @param sender the sender account id - * @param receiver the receiver account id - * @return the updated transaction - */ - public TransferTransaction addApprovedNftTransfer(NftId nftId, AccountId sender, AccountId receiver) { - return doAddNftTransfer(nftId, sender, receiver, true); - } - - /** - * @deprecated - Use {@link #addApprovedNftTransfer(NftId, AccountId, AccountId)} instead - * @param nftId the NFT id - * @param isApproved whether the transfer is approved - * @return {@code this} - */ - @Deprecated - public TransferTransaction setNftTransferApproval(NftId nftId, boolean isApproved) { - requireNotFrozen(); - - for (var transfer : nftTransfers) { - if (transfer.tokenId.equals(nftId.tokenId) && transfer.serial == nftId.serial) { - transfer.isApproved = isApproved; - return this; - } - } - - return this; - } - /** * Extract the of hbar transfers. * - * @return list of hbar transfers + * @return list of hbar transfers */ public Map getHbarTransfers() { Map transfers = new HashMap<>(); @@ -431,9 +136,9 @@ private TransferTransaction doAddHbarTransfer(AccountId accountId, Hbar value, b /** * Add a non approved hbar transfer to an EVM address. * - * @param evmAddress the EVM address - * @param value the value - * @return the updated transaction + * @param evmAddress the EVM address + * @param value the value + * @return the updated transaction */ public TransferTransaction addHbarTransfer(EvmAddress evmAddress, Hbar value) { AccountId accountId = AccountId.fromEvmAddress(evmAddress); @@ -443,9 +148,9 @@ public TransferTransaction addHbarTransfer(EvmAddress evmAddress, Hbar value) { /** * Add a non approved hbar transfer. * - * @param accountId the account id - * @param value the value - * @return the updated transaction + * @param accountId the account id + * @param value the value + * @return the updated transaction */ public TransferTransaction addHbarTransfer(AccountId accountId, Hbar value) { return doAddHbarTransfer(accountId, value, false); @@ -454,19 +159,19 @@ public TransferTransaction addHbarTransfer(AccountId accountId, Hbar value) { /** * Add an approved hbar transfer. * - * @param accountId the account id - * @param value the value - * @return the updated transaction + * @param accountId the account id + * @param value the value + * @return the updated transaction */ public TransferTransaction addApprovedHbarTransfer(AccountId accountId, Hbar value) { return doAddHbarTransfer(accountId, value, true); } /** - * @deprecated - Use {@link #addApprovedHbarTransfer(AccountId, Hbar)} instead - * @param accountId the account id - * @param isApproved whether the transfer is approved + * @param accountId the account id + * @param isApproved whether the transfer is approved * @return {@code this} + * @deprecated - Use {@link #addApprovedHbarTransfer(AccountId, Hbar)} instead */ @Deprecated public TransferTransaction setHbarTransferApproval(AccountId accountId, boolean isApproved) { @@ -485,80 +190,21 @@ public TransferTransaction setHbarTransferApproval(AccountId accountId, boolean /** * Build the transaction body. * - * @return {@link - * com.hedera.hashgraph.sdk.proto.CryptoTransferTransactionBody} + * @return {@link com.hedera.hashgraph.sdk.proto.CryptoTransferTransactionBody} */ CryptoTransferTransactionBody.Builder build() { - var tokenTransfers = new ArrayList(); - - this.hbarTransfers.sort(Comparator.comparing((HbarTransfer a) -> a.accountId).thenComparing(a -> a.isApproved)); - this.tokenTransfers.sort(Comparator.comparing((TokenTransfer a) -> a.tokenId).thenComparing(a -> a.accountId).thenComparing(a -> a.isApproved)); - this.nftTransfers.sort(Comparator.comparing((TokenNftTransfer a) -> a.tokenId).thenComparing(a -> a.sender).thenComparing(a -> a.receiver).thenComparing(a -> a.serial)); - - var i = 0; - var j = 0; - - // Effectively merge sort - while (i < this.tokenTransfers.size() || j < this.nftTransfers.size()) { - if (i < this.tokenTransfers.size() && j < this.nftTransfers.size()) { - var iTokenId = this.tokenTransfers.get(i).tokenId; - var jTokenId = this.nftTransfers.get(j).tokenId; - var last = !tokenTransfers.isEmpty() ? tokenTransfers.get(tokenTransfers.size() - 1) : null; - var lastTokenId = last != null ? last.tokenId : null; - - if (last != null && iTokenId.compareTo(lastTokenId) == 0) { - last.transfers.add(this.tokenTransfers.get(i++)); - continue; - } - - if (last != null && jTokenId.compareTo(lastTokenId) == 0) { - last.nftTransfers.add(this.nftTransfers.get(j++)); - continue; - } - - var result = iTokenId.compareTo(jTokenId); - - if (result == 0) { - tokenTransfers.add(new TokenTransferList(iTokenId, this.tokenTransfers.get(i).expectedDecimals, this.tokenTransfers.get(i++), this.nftTransfers.get(j++))); - } else if (result < 0) { - tokenTransfers.add(new TokenTransferList(iTokenId, this.tokenTransfers.get(i).expectedDecimals, this.tokenTransfers.get(i++), null)); - } else { - tokenTransfers.add(new TokenTransferList(jTokenId, null, null, this.nftTransfers.get(j++))); - } - } else if (i < this.tokenTransfers.size()) { - var iTokenId = this.tokenTransfers.get(i).tokenId; - var last = !tokenTransfers.isEmpty() ? tokenTransfers.get(tokenTransfers.size() - 1) : null; - var lastTokenId = last != null ? last.tokenId : null; - - if (last != null && iTokenId.compareTo(lastTokenId) == 0) { - last.transfers.add(this.tokenTransfers.get(i++)); - continue; - } - - tokenTransfers.add(new TokenTransferList(iTokenId, this.tokenTransfers.get(i).expectedDecimals, this.tokenTransfers.get(i++), null)); - } else { - var jTokenId = this.nftTransfers.get(j).tokenId; - var last = !tokenTransfers.isEmpty() ? tokenTransfers.get(tokenTransfers.size() - 1) : null; - var lastTokenId = last != null ? last.tokenId : null; - - if (last != null && jTokenId.compareTo(lastTokenId) == 0) { - last.nftTransfers.add(this.nftTransfers.get(j++)); - continue; - } - - tokenTransfers.add(new TokenTransferList(jTokenId, null, null, this.nftTransfers.get(j++))); - } - } + var transfers = sortTransfersAndBuild(); var builder = CryptoTransferTransactionBody.newBuilder(); - var transfers = TransferList.newBuilder(); + this.hbarTransfers.sort(Comparator.comparing((HbarTransfer a) -> a.accountId).thenComparing(a -> a.isApproved)); + var hbarTransfersList = TransferList.newBuilder(); for (var transfer : hbarTransfers) { - transfers.addAccountAmounts(transfer.toProtobuf()); + hbarTransfersList.addAccountAmounts(transfer.toProtobuf()); } - builder.setTransfers(transfers); + builder.setTransfers(hbarTransfersList); - for (var transfer : tokenTransfers) { + for (var transfer : transfers) { builder.addTokenTransfers(transfer.toProtobuf()); } @@ -567,20 +213,10 @@ CryptoTransferTransactionBody.Builder build() { @Override void validateChecksums(Client client) throws BadEntityIdException { + super.validateChecksums(client); for (var transfer : hbarTransfers) { transfer.accountId.validateChecksum(client); } - - for (var transfer : nftTransfers) { - transfer.tokenId.validateChecksum(client); - transfer.sender.validateChecksum(client); - transfer.receiver.validateChecksum(client); - } - - for (var transfer : tokenTransfers) { - transfer.tokenId.validateChecksum(client); - transfer.accountId.validateChecksum(client); - } } @Override diff --git a/sdk/src/test/java/com/hedera/hashgraph/sdk/PendingAirdropIdTest.java b/sdk/src/test/java/com/hedera/hashgraph/sdk/PendingAirdropIdTest.java new file mode 100644 index 000000000..8ecbdc6f1 --- /dev/null +++ b/sdk/src/test/java/com/hedera/hashgraph/sdk/PendingAirdropIdTest.java @@ -0,0 +1,163 @@ +/*- + * + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PendingAirdropIdTest { + private AccountId sender; + private AccountId receiver; + private TokenId tokenId; + private NftId nftId; + + @BeforeEach + void setUp() { + sender = new AccountId(1001); + receiver = new AccountId(1002); + tokenId = new TokenId(1003); + nftId = new NftId(new TokenId(1004), 1); + } + + @Test + void testConstructorWithTokenId() { + PendingAirdropId pendingAirdropId = new PendingAirdropId(sender, receiver, tokenId); + + assertEquals(sender, pendingAirdropId.getSender()); + assertEquals(receiver, pendingAirdropId.getReceiver()); + assertEquals(tokenId, pendingAirdropId.getTokenId()); + assertNull(pendingAirdropId.getNftId()); + } + + @Test + void testConstructorWithNftId() { + PendingAirdropId pendingAirdropId = new PendingAirdropId(sender, receiver, nftId); + + assertEquals(sender, pendingAirdropId.getSender()); + assertEquals(receiver, pendingAirdropId.getReceiver()); + assertEquals(nftId, pendingAirdropId.getNftId()); + assertNull(pendingAirdropId.getTokenId()); + } + + @Test + void testSetSender() { + PendingAirdropId pendingAirdropId = new PendingAirdropId(); + pendingAirdropId.setSender(sender); + + assertEquals(sender, pendingAirdropId.getSender()); + } + + @Test + void testSetReceiver() { + PendingAirdropId pendingAirdropId = new PendingAirdropId(); + pendingAirdropId.setReceiver(receiver); + + assertEquals(receiver, pendingAirdropId.getReceiver()); + } + + @Test + void testSetTokenId() { + PendingAirdropId pendingAirdropId = new PendingAirdropId(); + pendingAirdropId.setTokenId(tokenId); + + assertEquals(tokenId, pendingAirdropId.getTokenId()); + } + + @Test + void testSetNftId() { + PendingAirdropId pendingAirdropId = new PendingAirdropId(); + pendingAirdropId.setNftId(nftId); + + assertEquals(nftId, pendingAirdropId.getNftId()); + } + + @Test + void testToProtobufWithTokenId() { + PendingAirdropId pendingAirdropId = new PendingAirdropId(sender, receiver, tokenId); + com.hedera.hashgraph.sdk.proto.PendingAirdropId proto = pendingAirdropId.toProtobuf(); + + assertNotNull(proto); + assertEquals(sender.toProtobuf(), proto.getSenderId()); + assertEquals(receiver.toProtobuf(), proto.getReceiverId()); + assertEquals(tokenId.toProtobuf(), proto.getFungibleTokenType()); + } + + @Test + void testToProtobufWithNftId() { + PendingAirdropId pendingAirdropId = new PendingAirdropId(sender, receiver, nftId); + com.hedera.hashgraph.sdk.proto.PendingAirdropId proto = pendingAirdropId.toProtobuf(); + + assertNotNull(proto); + assertEquals(sender.toProtobuf(), proto.getSenderId()); + assertEquals(receiver.toProtobuf(), proto.getReceiverId()); + assertEquals(nftId.toProtobuf(), proto.getNonFungibleToken()); + } + + @Test + void testFromProtobufWithTokenId() { + com.hedera.hashgraph.sdk.proto.PendingAirdropId proto = com.hedera.hashgraph.sdk.proto.PendingAirdropId.newBuilder() + .setSenderId(sender.toProtobuf()) + .setReceiverId(receiver.toProtobuf()) + .setFungibleTokenType(tokenId.toProtobuf()) + .build(); + + PendingAirdropId pendingAirdropId = PendingAirdropId.fromProtobuf(proto); + + assertNotNull(pendingAirdropId); + assertEquals(sender, pendingAirdropId.getSender()); + assertEquals(receiver, pendingAirdropId.getReceiver()); + assertEquals(tokenId, pendingAirdropId.getTokenId()); + assertNull(pendingAirdropId.getNftId()); + } + + @Test + void testFromProtobufWithNftId() { + com.hedera.hashgraph.sdk.proto.PendingAirdropId proto = com.hedera.hashgraph.sdk.proto.PendingAirdropId.newBuilder() + .setSenderId(sender.toProtobuf()) + .setReceiverId(receiver.toProtobuf()) + .setNonFungibleToken(nftId.toProtobuf()) + .build(); + + PendingAirdropId pendingAirdropId = PendingAirdropId.fromProtobuf(proto); + + assertNotNull(pendingAirdropId); + assertEquals(sender, pendingAirdropId.getSender()); + assertEquals(receiver, pendingAirdropId.getReceiver()); + assertEquals(nftId, pendingAirdropId.getNftId()); + assertNull(pendingAirdropId.getTokenId()); + } + + @Test + void testToString() { + PendingAirdropId pendingAirdropId = new PendingAirdropId(sender, receiver, tokenId); + String result = pendingAirdropId.toString(); + + assertTrue(result.contains("sender")); + assertTrue(result.contains("receiver")); + assertTrue(result.contains("tokenId")); + assertTrue(result.contains("nftId")); + } +} diff --git a/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenAirdropTransactionTest.java b/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenAirdropTransactionTest.java new file mode 100644 index 000000000..85dc3e51e --- /dev/null +++ b/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenAirdropTransactionTest.java @@ -0,0 +1,216 @@ +/*- + * + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.hedera.hashgraph.sdk.proto.SchedulableTransactionBody; +import com.hedera.hashgraph.sdk.proto.TokenAirdropTransactionBody; +import com.hedera.hashgraph.sdk.proto.TokenServiceGrpc; +import io.github.jsonSnapshot.SnapshotMatcher; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class TokenAirdropTransactionTest { + private static final PrivateKey privateKey = PrivateKey.fromString( + "302e020100300506032b657004220420db484b828e64b2d8f12ce3c0a0e93a0b8cce7af1bb8f39c97732394482538e10"); + + final Instant validStart = Instant.ofEpochSecond(1554158542); + private TokenAirdropTransaction transaction; + + @BeforeEach + public void setUp() { + transaction = new TokenAirdropTransaction(); + } + + @BeforeAll + public static void beforeAll() { + SnapshotMatcher.start(); + } + + @AfterAll + public static void afterAll() { + SnapshotMatcher.validateSnapshots(); + } + + @Test + void shouldSerialize() { + SnapshotMatcher.expect(spawnTestTransaction() + .toString() + ).toMatchSnapshot(); + } + + private TokenAirdropTransaction spawnTestTransaction() { + return new TokenAirdropTransaction() + .setNodeAccountIds(Arrays.asList(AccountId.fromString("0.0.5005"), AccountId.fromString("0.0.5006"))) + .setTransactionId(TransactionId.withValidStart(AccountId.fromString("0.0.5006"), validStart)) + .addTokenTransfer(TokenId.fromString("0.0.5"), AccountId.fromString("0.0.5008"), 400) + .addTokenTransferWithDecimals(TokenId.fromString("0.0.5"), AccountId.fromString("0.0.5006"), -800, 3) + .addTokenTransferWithDecimals(TokenId.fromString("0.0.5"), AccountId.fromString("0.0.5007"), 400, 3) + .addTokenTransfer(TokenId.fromString("0.0.4"), AccountId.fromString("0.0.5008"), 1) + .addTokenTransfer(TokenId.fromString("0.0.4"), AccountId.fromString("0.0.5006"), -1) + .addNftTransfer(TokenId.fromString("0.0.3").nft(2), AccountId.fromString("0.0.5008"), AccountId.fromString("0.0.5007")) + .addNftTransfer(TokenId.fromString("0.0.3").nft(1), AccountId.fromString("0.0.5008"), AccountId.fromString("0.0.5007")) + .addNftTransfer(TokenId.fromString("0.0.3").nft(3), AccountId.fromString("0.0.5008"), AccountId.fromString("0.0.5006")) + .addNftTransfer(TokenId.fromString("0.0.3").nft(4), AccountId.fromString("0.0.5007"), AccountId.fromString("0.0.5006")) + .addNftTransfer(TokenId.fromString("0.0.2").nft(4), AccountId.fromString("0.0.5007"), AccountId.fromString("0.0.5006")) + .addApprovedTokenTransfer(TokenId.fromString("0.0.4"), AccountId.fromString("0.0.5006"), 123) + .addApprovedNftTransfer(new NftId(TokenId.fromString("0.0.4"), 4), AccountId.fromString("0.0.5005"), AccountId.fromString("0.0.5006")) + .setMaxTransactionFee(Hbar.fromTinybars(100_000)) + .freeze() + .sign(privateKey); + } + + @Test + void shouldBytes() throws Exception { + var tx = spawnTestTransaction(); + var tx2 = TokenAirdropTransaction.fromBytes(tx.toBytes()); + assertThat(tx2.toString()).isEqualTo(tx.toString()); + } + + @Test + void decimalsMustBeConsistent() { + assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> { + new TokenAirdropTransaction() + .addTokenTransferWithDecimals(TokenId.fromString("0.0.5"), AccountId.fromString("0.0.8"), 100, 2) + .addTokenTransferWithDecimals(TokenId.fromString("0.0.5"), AccountId.fromString("0.0.7"), -100, 3); + }); + } + + @Test + void canGetDecimals() { + var tx = new TokenAirdropTransaction(); + assertThat(tx.getTokenIdDecimals().get(TokenId.fromString("0.0.5"))).isNull(); + tx.addTokenTransfer(TokenId.fromString("0.0.5"), AccountId.fromString("0.0.8"), 100); + assertThat(tx.getTokenIdDecimals().get(TokenId.fromString("0.0.5"))).isNull(); + tx.addTokenTransferWithDecimals(TokenId.fromString("0.0.5"), AccountId.fromString("0.0.7"), -100, 5); + assertThat(tx.getTokenIdDecimals().get(TokenId.fromString("0.0.5"))).isEqualTo(5); + } + + @Test + void fromScheduledTransaction() { + var transactionBody = SchedulableTransactionBody.newBuilder() + .setTokenAirdrop(TokenAirdropTransactionBody.newBuilder().build()) + .build(); + + var tx = Transaction.fromScheduledTransaction(transactionBody); + + assertThat(tx).isInstanceOf(TokenAirdropTransaction.class); + } + + @Test + void testDefaultMaxTransactionFeeIsSet() { + assertEquals(new Hbar(1), transaction.getDefaultMaxTransactionFee(), "Default max transaction fee should be 1 Hbar"); + } + + @Test + void testAddTokenTransfer() { + TokenId tokenId = new TokenId(0, 0, 123); + AccountId accountId = new AccountId(0, 0, 456); + long value = 1000L; + + transaction.addTokenTransfer(tokenId, accountId, value); + + Map> tokenTransfers = transaction.getTokenTransfers(); + assertTrue(tokenTransfers.containsKey(tokenId)); + assertEquals(1, tokenTransfers.get(tokenId).size()); + assertEquals(value, tokenTransfers.get(tokenId).get(accountId)); + } + + @Test + void testAddApprovedTokenTransfer() { + TokenId tokenId = new TokenId(0, 0, 123); + AccountId accountId = new AccountId(0, 0, 456); + long value = 1000L; + + transaction.addApprovedTokenTransfer(tokenId, accountId, value); + + Map> tokenTransfers = transaction.getTokenTransfers(); + assertTrue(tokenTransfers.containsKey(tokenId)); + assertEquals(1, tokenTransfers.get(tokenId).size()); + assertEquals(value, tokenTransfers.get(tokenId).get(accountId)); + } + + @Test + void testAddNftTransfer() { + NftId nftId = new NftId(new TokenId(0, 0, 123), 1); + AccountId sender = new AccountId(0, 0, 456); + AccountId receiver = new AccountId(0, 0, 789); + + transaction.addNftTransfer(nftId, sender, receiver); + + Map> nftTransfers = transaction.getTokenNftTransfers(); + assertTrue(nftTransfers.containsKey(nftId.tokenId)); + assertEquals(1, nftTransfers.get(nftId.tokenId).size()); + assertEquals(sender, nftTransfers.get(nftId.tokenId).get(0).sender); + assertEquals(receiver, nftTransfers.get(nftId.tokenId).get(0).receiver); + } + + @Test + void testAddApprovedNftTransfer() { + NftId nftId = new NftId(new TokenId(0, 0, 123), 1); + AccountId sender = new AccountId(0, 0, 456); + AccountId receiver = new AccountId(0, 0, 789); + + transaction.addApprovedNftTransfer(nftId, sender, receiver); + + Map> nftTransfers = transaction.getTokenNftTransfers(); + assertTrue(nftTransfers.containsKey(nftId.tokenId)); + assertEquals(1, nftTransfers.get(nftId.tokenId).size()); + assertEquals(sender, nftTransfers.get(nftId.tokenId).get(0).sender); + assertEquals(receiver, nftTransfers.get(nftId.tokenId).get(0).receiver); + } + + @Test + void testGetTokenIdDecimals() { + TokenId tokenId = new TokenId(0, 0, 123); + AccountId accountId = new AccountId(0, 0, 456); + long value = 1000L; + int decimals = 8; + + transaction.addTokenTransferWithDecimals(tokenId, accountId, value, decimals); + + Map decimalsMap = transaction.getTokenIdDecimals(); + assertTrue(decimalsMap.containsKey(tokenId)); + assertEquals(decimals, decimalsMap.get(tokenId)); + } + + @Test + void testBuildTransactionBody() { + TokenAirdropTransactionBody.Builder builder = spawnTestTransaction().build(); + + assertNotNull(builder); + } + + @Test + void testGetMethodDescriptor() { + assertEquals(TokenServiceGrpc.getAirdropTokensMethod(), transaction.getMethodDescriptor()); + } +} diff --git a/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenAirdropTransactionTest.snap b/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenAirdropTransactionTest.snap new file mode 100644 index 000000000..87049b1a0 --- /dev/null +++ b/sdk/src/test/java/com/hedera/hashgraph/sdk/TokenAirdropTransactionTest.snap @@ -0,0 +1,3 @@ +com.hedera.hashgraph.sdk.TokenAirdropTransactionTest.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_airdrop {\n token_transfers {\n nft_transfers {\n receiver_account_i_d {\n account_num: 5006\n realm_num: 0\n shard_num: 0\n }\n sender_account_i_d {\n account_num: 5007\n realm_num: 0\n shard_num: 0\n }\n serial_number: 4\n }\n token {\n realm_num: 0\n shard_num: 0\n token_num: 2\n }\n }\n token_transfers {\n nft_transfers {\n receiver_account_i_d {\n account_num: 5006\n realm_num: 0\n shard_num: 0\n }\n sender_account_i_d {\n account_num: 5007\n realm_num: 0\n shard_num: 0\n }\n serial_number: 4\n }\n nft_transfers {\n receiver_account_i_d {\n account_num: 5006\n realm_num: 0\n shard_num: 0\n }\n sender_account_i_d {\n account_num: 5008\n realm_num: 0\n shard_num: 0\n }\n serial_number: 3\n }\n nft_transfers {\n receiver_account_i_d {\n account_num: 5007\n realm_num: 0\n shard_num: 0\n }\n sender_account_i_d {\n account_num: 5008\n realm_num: 0\n shard_num: 0\n }\n serial_number: 1\n }\n nft_transfers {\n receiver_account_i_d {\n account_num: 5007\n realm_num: 0\n shard_num: 0\n }\n sender_account_i_d {\n account_num: 5008\n realm_num: 0\n shard_num: 0\n }\n serial_number: 2\n }\n token {\n realm_num: 0\n shard_num: 0\n token_num: 3\n }\n }\n token_transfers {\n nft_transfers {\n is_approval: true\n receiver_account_i_d {\n account_num: 5006\n realm_num: 0\n shard_num: 0\n }\n sender_account_i_d {\n account_num: 5005\n realm_num: 0\n shard_num: 0\n }\n serial_number: 4\n }\n token {\n realm_num: 0\n shard_num: 0\n token_num: 4\n }\n transfers {\n account_i_d {\n account_num: 5006\n realm_num: 0\n shard_num: 0\n }\n amount: -1\n }\n transfers {\n account_i_d {\n account_num: 5006\n realm_num: 0\n shard_num: 0\n }\n amount: 123\n is_approval: true\n }\n transfers {\n account_i_d {\n account_num: 5008\n realm_num: 0\n shard_num: 0\n }\n amount: 1\n }\n }\n token_transfers {\n expected_decimals {\n value: 3\n }\n token {\n realm_num: 0\n shard_num: 0\n token_num: 5\n }\n transfers {\n account_i_d {\n account_num: 5006\n realm_num: 0\n shard_num: 0\n }\n amount: -800\n }\n transfers {\n account_i_d {\n account_num: 5007\n realm_num: 0\n shard_num: 0\n }\n amount: 400\n }\n transfers {\n account_i_d {\n account_num: 5008\n realm_num: 0\n shard_num: 0\n }\n amount: 400\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/TransactionRecordTest.java b/sdk/src/test/java/com/hedera/hashgraph/sdk/TransactionRecordTest.java index 087a929e1..0616d408d 100644 --- a/sdk/src/test/java/com/hedera/hashgraph/sdk/TransactionRecordTest.java +++ b/sdk/src/test/java/com/hedera/hashgraph/sdk/TransactionRecordTest.java @@ -82,7 +82,11 @@ private static TransactionRecord spawnRecordExample(@Nullable ByteString prngByt List.of(new Transfer(AccountId.fromString("1.2.3"), Hbar.from(8))), prngBytes, prngNumber, - ByteString.copyFrom("0x00", StandardCharsets.UTF_8) + ByteString.copyFrom("0x00", StandardCharsets.UTF_8), + List.of( + new PendingAirdropRecord(new PendingAirdropId(AccountId.fromString("0.0.123"), AccountId.fromString("0.0.124"), NftId.fromString("0.0.5005/1234")), 123), + new PendingAirdropRecord(new PendingAirdropId(AccountId.fromString("0.0.123"), AccountId.fromString("0.0.124"), TokenId.fromString("0.0.12345")), 123) + ) ); } diff --git a/sdk/src/test/java/com/hedera/hashgraph/sdk/TransactionRecordTest.snap b/sdk/src/test/java/com/hedera/hashgraph/sdk/TransactionRecordTest.snap index 152bccc1c..d0e938192 100644 --- a/sdk/src/test/java/com/hedera/hashgraph/sdk/TransactionRecordTest.snap +++ b/sdk/src/test/java/com/hedera/hashgraph/sdk/TransactionRecordTest.snap @@ -1,8 +1,8 @@ com.hedera.hashgraph.sdk.TransactionRecordTest.shouldSerialize2=[ - "TransactionRecord{receipt=TransactionReceipt{transactionId=null, status=SCHEDULE_ALREADY_DELETED, exchangeRate=ExchangeRate{hbars=3, cents=4, expirationTime=2019-04-01T22:42:22Z, exchangeRateInCents=1.3333333333333333}, accountId=1.2.3, fileId=4.5.6, contractId=3.2.1, topicId=9.8.7, tokenId=6.5.4, topicSequenceNumber=3, topicRunningHash=[54, 56, 54, 102, 55, 55, 50, 48, 54, 101, 54, 102, 55, 55, 50, 48, 54, 50, 55, 50, 54, 102, 55, 55, 54, 101, 50, 48, 54, 51, 54, 102, 55, 55], totalSupply=30, scheduleId=1.1.1, scheduledTransactionId=3.3.3@1554158542.000000000, serials=[1, 2, 3], nodeId=1, duplicates=[], children=[]}, transactionHash=68656c6c6f, consensusTimestamp=2019-04-01T22:42:22Z, transactionId=3.3.3@1554158542.000000000, transactionMemo=memo, transactionFee=3000 tℏ, contractFunctionResult=ContractFunctionResult{contractId=1.2.3, evmAddress=1.2.98329e006610472e6b372c080833f6d79ed833cf, errorMessage=null, bloom=, gasUsed=0, logs=[], createdContractIds=[], stateChanges=[], gas=0, hbarAmount=0 tℏ, contractFunctionparametersBytes=, rawResult=00000000000000000000000000000000000000000000000000000000ffffffff7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000011223344556677889900aabbccddeeff00112233ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000d48656c6c6f2c20776f726c642100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001448656c6c6f2c20776f726c642c20616761696e21000000000000000000000000, senderAccountId=1.2.3, contractNonces=[], signerNonce=0}, transfers=[Transfer{accountId=4.4.4, amount=5 ℏ}], tokenTransfers={6.6.6={1.1.1=4}}, tokenNftTransfers={4.4.4=[TokenNftTransfer{tokenId=4.4.4, sender=1.2.3, receiver=3.2.1, serial=4, isApproved=true}]}, scheduleRef=3.3.3, assessedCustomFees=[AssessedCustomFee{amount=4, tokenId=4.5.6, feeCollectorAccountId=8.6.5, payerAccountIdList=[3.3.3]}], automaticTokenAssociations=[TokenAssociation{tokenId=5.4.3, accountId=8.7.6}], aliasKey=3036301006072a8648ce3d020106052b8104000a03220002703a9370b0443be6ae7c507b0aec81a55e94e4a863b9655360bd65358caa6588, children=[], duplicates=[], parentConsensusTimestamp=2019-04-01T22:42:22Z, ethereumHash=536f6d652068617368, paidStakingRewards=[Transfer{accountId=1.2.3, amount=8 ℏ}], prngBytes=null, prngNumber=4, evmAddress=30783030}" + "TransactionRecord{receipt=TransactionReceipt{transactionId=null, status=SCHEDULE_ALREADY_DELETED, exchangeRate=ExchangeRate{hbars=3, cents=4, expirationTime=2019-04-01T22:42:22Z, exchangeRateInCents=1.3333333333333333}, accountId=1.2.3, fileId=4.5.6, contractId=3.2.1, topicId=9.8.7, tokenId=6.5.4, topicSequenceNumber=3, topicRunningHash=[54, 56, 54, 102, 55, 55, 50, 48, 54, 101, 54, 102, 55, 55, 50, 48, 54, 50, 55, 50, 54, 102, 55, 55, 54, 101, 50, 48, 54, 51, 54, 102, 55, 55], totalSupply=30, scheduleId=1.1.1, scheduledTransactionId=3.3.3@1554158542.000000000, serials=[1, 2, 3], nodeId=1, duplicates=[], children=[]}, transactionHash=68656c6c6f, consensusTimestamp=2019-04-01T22:42:22Z, transactionId=3.3.3@1554158542.000000000, transactionMemo=memo, transactionFee=3000 tℏ, contractFunctionResult=ContractFunctionResult{contractId=1.2.3, evmAddress=1.2.98329e006610472e6b372c080833f6d79ed833cf, errorMessage=null, bloom=, gasUsed=0, logs=[], createdContractIds=[], stateChanges=[], gas=0, hbarAmount=0 tℏ, contractFunctionparametersBytes=, rawResult=00000000000000000000000000000000000000000000000000000000ffffffff7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000011223344556677889900aabbccddeeff00112233ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000d48656c6c6f2c20776f726c642100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001448656c6c6f2c20776f726c642c20616761696e21000000000000000000000000, senderAccountId=1.2.3, contractNonces=[], signerNonce=0}, transfers=[Transfer{accountId=4.4.4, amount=5 ℏ}], tokenTransfers={6.6.6={1.1.1=4}}, tokenNftTransfers={4.4.4=[TokenNftTransfer{tokenId=4.4.4, sender=1.2.3, receiver=3.2.1, serial=4, isApproved=true}]}, scheduleRef=3.3.3, assessedCustomFees=[AssessedCustomFee{amount=4, tokenId=4.5.6, feeCollectorAccountId=8.6.5, payerAccountIdList=[3.3.3]}], automaticTokenAssociations=[TokenAssociation{tokenId=5.4.3, accountId=8.7.6}], aliasKey=3036301006072a8648ce3d020106052b8104000a03220002703a9370b0443be6ae7c507b0aec81a55e94e4a863b9655360bd65358caa6588, children=[], duplicates=[], parentConsensusTimestamp=2019-04-01T22:42:22Z, ethereumHash=536f6d652068617368, paidStakingRewards=[Transfer{accountId=1.2.3, amount=8 ℏ}], prngBytes=null, prngNumber=4, evmAddress=30783030, pendingAirdropRecords=[PendingAirdropRecord{pendingAirdropId=PendingAirdropId{sender=0.0.123, receiver=0.0.124, tokenId=null, nftId=0.0.5005/1234}, pendingAirdropAmount=123}, PendingAirdropRecord{pendingAirdropId=PendingAirdropId{sender=0.0.123, receiver=0.0.124, tokenId=0.0.12345, nftId=null}, pendingAirdropAmount=123}]}" ] com.hedera.hashgraph.sdk.TransactionRecordTest.shouldSerialize=[ - "TransactionRecord{receipt=TransactionReceipt{transactionId=null, status=SCHEDULE_ALREADY_DELETED, exchangeRate=ExchangeRate{hbars=3, cents=4, expirationTime=2019-04-01T22:42:22Z, exchangeRateInCents=1.3333333333333333}, accountId=1.2.3, fileId=4.5.6, contractId=3.2.1, topicId=9.8.7, tokenId=6.5.4, topicSequenceNumber=3, topicRunningHash=[54, 56, 54, 102, 55, 55, 50, 48, 54, 101, 54, 102, 55, 55, 50, 48, 54, 50, 55, 50, 54, 102, 55, 55, 54, 101, 50, 48, 54, 51, 54, 102, 55, 55], totalSupply=30, scheduleId=1.1.1, scheduledTransactionId=3.3.3@1554158542.000000000, serials=[1, 2, 3], nodeId=1, duplicates=[], children=[]}, transactionHash=68656c6c6f, consensusTimestamp=2019-04-01T22:42:22Z, transactionId=3.3.3@1554158542.000000000, transactionMemo=memo, transactionFee=3000 tℏ, contractFunctionResult=ContractFunctionResult{contractId=1.2.3, evmAddress=1.2.98329e006610472e6b372c080833f6d79ed833cf, errorMessage=null, bloom=, gasUsed=0, logs=[], createdContractIds=[], stateChanges=[], gas=0, hbarAmount=0 tℏ, contractFunctionparametersBytes=, rawResult=00000000000000000000000000000000000000000000000000000000ffffffff7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000011223344556677889900aabbccddeeff00112233ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000d48656c6c6f2c20776f726c642100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001448656c6c6f2c20776f726c642c20616761696e21000000000000000000000000, senderAccountId=1.2.3, contractNonces=[], signerNonce=0}, transfers=[Transfer{accountId=4.4.4, amount=5 ℏ}], tokenTransfers={6.6.6={1.1.1=4}}, tokenNftTransfers={4.4.4=[TokenNftTransfer{tokenId=4.4.4, sender=1.2.3, receiver=3.2.1, serial=4, isApproved=true}]}, scheduleRef=3.3.3, assessedCustomFees=[AssessedCustomFee{amount=4, tokenId=4.5.6, feeCollectorAccountId=8.6.5, payerAccountIdList=[3.3.3]}], automaticTokenAssociations=[TokenAssociation{tokenId=5.4.3, accountId=8.7.6}], aliasKey=3036301006072a8648ce3d020106052b8104000a03220002703a9370b0443be6ae7c507b0aec81a55e94e4a863b9655360bd65358caa6588, children=[], duplicates=[], parentConsensusTimestamp=2019-04-01T22:42:22Z, ethereumHash=536f6d652068617368, paidStakingRewards=[Transfer{accountId=1.2.3, amount=8 ℏ}], prngBytes=766572792072616e646f6d206279746573, prngNumber=null, evmAddress=30783030}" + "TransactionRecord{receipt=TransactionReceipt{transactionId=null, status=SCHEDULE_ALREADY_DELETED, exchangeRate=ExchangeRate{hbars=3, cents=4, expirationTime=2019-04-01T22:42:22Z, exchangeRateInCents=1.3333333333333333}, accountId=1.2.3, fileId=4.5.6, contractId=3.2.1, topicId=9.8.7, tokenId=6.5.4, topicSequenceNumber=3, topicRunningHash=[54, 56, 54, 102, 55, 55, 50, 48, 54, 101, 54, 102, 55, 55, 50, 48, 54, 50, 55, 50, 54, 102, 55, 55, 54, 101, 50, 48, 54, 51, 54, 102, 55, 55], totalSupply=30, scheduleId=1.1.1, scheduledTransactionId=3.3.3@1554158542.000000000, serials=[1, 2, 3], nodeId=1, duplicates=[], children=[]}, transactionHash=68656c6c6f, consensusTimestamp=2019-04-01T22:42:22Z, transactionId=3.3.3@1554158542.000000000, transactionMemo=memo, transactionFee=3000 tℏ, contractFunctionResult=ContractFunctionResult{contractId=1.2.3, evmAddress=1.2.98329e006610472e6b372c080833f6d79ed833cf, errorMessage=null, bloom=, gasUsed=0, logs=[], createdContractIds=[], stateChanges=[], gas=0, hbarAmount=0 tℏ, contractFunctionparametersBytes=, rawResult=00000000000000000000000000000000000000000000000000000000ffffffff7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000011223344556677889900aabbccddeeff00112233ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000d48656c6c6f2c20776f726c642100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001448656c6c6f2c20776f726c642c20616761696e21000000000000000000000000, senderAccountId=1.2.3, contractNonces=[], signerNonce=0}, transfers=[Transfer{accountId=4.4.4, amount=5 ℏ}], tokenTransfers={6.6.6={1.1.1=4}}, tokenNftTransfers={4.4.4=[TokenNftTransfer{tokenId=4.4.4, sender=1.2.3, receiver=3.2.1, serial=4, isApproved=true}]}, scheduleRef=3.3.3, assessedCustomFees=[AssessedCustomFee{amount=4, tokenId=4.5.6, feeCollectorAccountId=8.6.5, payerAccountIdList=[3.3.3]}], automaticTokenAssociations=[TokenAssociation{tokenId=5.4.3, accountId=8.7.6}], aliasKey=3036301006072a8648ce3d020106052b8104000a03220002703a9370b0443be6ae7c507b0aec81a55e94e4a863b9655360bd65358caa6588, children=[], duplicates=[], parentConsensusTimestamp=2019-04-01T22:42:22Z, ethereumHash=536f6d652068617368, paidStakingRewards=[Transfer{accountId=1.2.3, amount=8 ℏ}], prngBytes=766572792072616e646f6d206279746573, prngNumber=null, evmAddress=30783030, pendingAirdropRecords=[PendingAirdropRecord{pendingAirdropId=PendingAirdropId{sender=0.0.123, receiver=0.0.124, tokenId=null, nftId=0.0.5005/1234}, pendingAirdropAmount=123}, PendingAirdropRecord{pendingAirdropId=PendingAirdropId{sender=0.0.123, receiver=0.0.124, tokenId=0.0.12345, nftId=null}, pendingAirdropAmount=123}]}" ] \ No newline at end of file diff --git a/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/EntityHelper.java b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/EntityHelper.java index dddaa946e..78053bd3f 100644 --- a/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/EntityHelper.java +++ b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/EntityHelper.java @@ -41,6 +41,9 @@ public final class EntityHelper { private EntityHelper() {} + public static int fungibleInitialBalance = 1_000_000; + public static int mitedNfts = 10; + /** * Create a non-fungible unique token. * @@ -87,8 +90,8 @@ public static TokenId createFungibleToken(IntegrationTestEnv testEnv, int decima .setTokenSymbol("TFT") .setTokenMemo("I was created for integration tests") .setDecimals(decimals) - .setInitialSupply(1_000_000) - .setMaxSupply(1_000_000) + .setInitialSupply(fungibleInitialBalance) + .setMaxSupply(fungibleInitialBalance) .setTreasuryAccountId(testEnv.operatorId) .setSupplyType(TokenSupplyType.FINITE) .setAdminKey(testEnv.operatorKey) @@ -116,7 +119,7 @@ public static AccountId createAccount(IntegrationTestEnv testEnv, Key accountKey throws PrecheckStatusException, TimeoutException, ReceiptStatusException { return new AccountCreateTransaction() .setKey(accountKey) - .setInitialBalance(new Hbar(1)) + .setInitialBalance(new Hbar(10)) .setMaxAutomaticTokenAssociations(maxAutomaticTokenAssociations) .execute(testEnv.client) .getReceipt(testEnv.client) diff --git a/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/TokenAirdropTransactionIntegrationTest.java b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/TokenAirdropTransactionIntegrationTest.java new file mode 100644 index 000000000..30dea7dc6 --- /dev/null +++ b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/TokenAirdropTransactionIntegrationTest.java @@ -0,0 +1,493 @@ +/*- + * + * 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.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.hedera.hashgraph.sdk.AccountAllowanceApproveTransaction; +import com.hedera.hashgraph.sdk.AccountBalanceQuery; +import com.hedera.hashgraph.sdk.AccountCreateTransaction; +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.CustomFixedFee; +import com.hedera.hashgraph.sdk.Hbar; +import com.hedera.hashgraph.sdk.PrecheckStatusException; +import com.hedera.hashgraph.sdk.PrivateKey; +import com.hedera.hashgraph.sdk.PublicKey; +import com.hedera.hashgraph.sdk.Status; +import com.hedera.hashgraph.sdk.TokenAirdropTransaction; +import com.hedera.hashgraph.sdk.TokenAssociateTransaction; +import com.hedera.hashgraph.sdk.TokenCreateTransaction; +import com.hedera.hashgraph.sdk.TokenMintTransaction; +import com.hedera.hashgraph.sdk.TokenSupplyType; +import com.hedera.hashgraph.sdk.TransactionId; +import com.hedera.hashgraph.sdk.TransferTransaction; +import java.util.Collections; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class TokenAirdropTransactionIntegrationTest { + + private final int amount = 100; + + @Test + @DisplayName("Transfers tokens when the account is associated") + void canAirdropAssociatedTokens() 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 unlimited auto associations and receiverSig = false + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, -1); + + // airdrop the tokens + 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) + .getReceipt(testEnv.client); + + // 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("Tokens are in pending state when the account is not associated") + void canAirdropNonAssociatedTokens() 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 and receiverSig = false + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, 0); + + // airdrop the tokens + var txn = 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); + txn.setValidateStatus(true).getReceipt(testEnv.client); + var record = txn.getRecord(testEnv.client); + + // verify in the transaction record the pending airdrops + assertThat(record.pendingAirdropRecords).isNotNull(); + assertFalse(record.pendingAirdropRecords.isEmpty()); + + // 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("Airdrop creates a hollow account and transfers the tokens") + void canAirdropToAlias() 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; + + // airdrop the tokens to an alias + PrivateKey privateKey = PrivateKey.generateED25519(); + PublicKey publicKey = privateKey.getPublicKey(); + + AccountId aliasAccountId = publicKey.toAccountId(0, 0); + + // should lazy-create and transfer the tokens + new TokenAirdropTransaction() + .addNftTransfer(nftID.nft(nftSerials.get(0)), testEnv.operatorId, aliasAccountId) + .addNftTransfer(nftID.nft(nftSerials.get(1)), testEnv.operatorId, aliasAccountId) + .addTokenTransfer(tokenID, aliasAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + // verify the receiver holds the tokens via query + var receiverAccountBalance = new AccountBalanceQuery() + .setAccountId(aliasAccountId) + .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("Can airdrop with custom fees") + void canAirdropWithCustomFee() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create receiver unlimited auto associations and receiverSig = false + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = EntityHelper.createAccount(testEnv, receiverAccountKey, -1); + + // create fungible token with custom fee another token + var customFeeTokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // make the custom fee to be paid by the sender and the fee collector to be the operator account + CustomFixedFee fee = new CustomFixedFee() + .setFeeCollectorAccountId(testEnv.operatorId) + .setDenominatingTokenId(customFeeTokenID) + .setAmount(1) + .setAllCollectorsAreExempt(true); + + var tokenID = new TokenCreateTransaction() + .setTokenName("Test Fungible Token") + .setTokenSymbol("TFT") + .setTokenMemo("I was created for integration tests") + .setDecimals(3) + .setInitialSupply(fungibleInitialBalance) + .setMaxSupply(fungibleInitialBalance) + .setTreasuryAccountId(testEnv.operatorId) + .setSupplyType(TokenSupplyType.FINITE) + .setAdminKey(testEnv.operatorKey) + .setFreezeKey(testEnv.operatorKey) + .setSupplyKey(testEnv.operatorKey) + .setMetadataKey(testEnv.operatorKey) + .setPauseKey(testEnv.operatorKey) + .setCustomFees(Collections.singletonList(fee)) + .execute(testEnv.client) + .getReceipt(testEnv.client) + .tokenId; + + // create sender account with unlimited associations and send some tokens to it + var senderKey = PrivateKey.generateED25519(); + var senderAccountID = EntityHelper.createAccount(testEnv, senderKey, -1); + + // associate the token to the sender + new TokenAssociateTransaction() + .setAccountId(senderAccountID) + .setTokenIds(Collections.singletonList(customFeeTokenID)) + .freezeWith(testEnv.client) + .sign(senderKey) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + // send tokens to the sender + new TransferTransaction() + .addTokenTransfer(customFeeTokenID, testEnv.operatorId, -amount) + .addTokenTransfer(customFeeTokenID, senderAccountID, amount) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + new TransferTransaction() + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .addTokenTransfer(tokenID, senderAccountID, amount) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + // airdrop the tokens from the sender to the receiver + new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, senderAccountID, -amount) + .freezeWith(testEnv.client) + .sign(senderKey) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + // verify the custom fee has been paid by the sender to the collector + var receiverAccountBalance = new AccountBalanceQuery() + .setAccountId(receiverAccountId) + .execute(testEnv.client); + assertEquals(amount, receiverAccountBalance.tokens.get(tokenID)); + + var senderAccountBalance = new AccountBalanceQuery() + .setAccountId(senderAccountID) + .execute(testEnv.client); + assertEquals(0, senderAccountBalance.tokens.get(tokenID)); + assertEquals(amount - 1, senderAccountBalance.tokens.get(customFeeTokenID)); + + var operatorBalance = new AccountBalanceQuery() + .setAccountId(testEnv.operatorId) + .execute(testEnv.client); + assertEquals(fungibleInitialBalance - amount + 1, operatorBalance.tokens.get(customFeeTokenID)); + assertEquals(fungibleInitialBalance - amount, operatorBalance.tokens.get(tokenID)); + + testEnv.close(); + } + + @Test + @DisplayName("Can airdrop ft with receiverSig=true") + void canAirdropTokensWithReceiverSigRequiredFungible() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // create receiver with unlimited auto associations and receiverSig = true + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = new AccountCreateTransaction() + .setKey(receiverAccountKey) + .setInitialBalance(new Hbar(1)) + .setReceiverSignatureRequired(true) + .setMaxAutomaticTokenAssociations(-1) + .freezeWith(testEnv.client) + .sign(receiverAccountKey) + .execute(testEnv.client) + .getReceipt(testEnv.client) + .accountId; + + // airdrop the tokens + new TokenAirdropTransaction() + .addTokenTransfer(tokenID, receiverAccountId, amount) + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + testEnv.close(); + } + + @Test + @DisplayName("Can airdrop nft with receiverSig=true") + void canAirdropTokensWithReceiverSigRequiredNFT() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create nft + 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 unlimited auto associations and receiverSig = true + var receiverAccountKey = PrivateKey.generateED25519(); + var receiverAccountId = new AccountCreateTransaction() + .setKey(receiverAccountKey) + .setInitialBalance(new Hbar(1)) + .setReceiverSignatureRequired(true) + .setMaxAutomaticTokenAssociations(-1) + .freezeWith(testEnv.client) + .sign(receiverAccountKey) + .execute(testEnv.client) + .getReceipt(testEnv.client) + .accountId; + + // airdrop the tokens + new TokenAirdropTransaction() + .addNftTransfer(nftID.nft(nftSerials.get(0)), testEnv.operatorId, receiverAccountId) + .addNftTransfer(nftID.nft(nftSerials.get(1)), testEnv.operatorId, receiverAccountId) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + testEnv.close(); + } + + @Test + @DisplayName("Cannot airdrop ft with no balance") + void cannotAirdropTokensWithAllowanceAndWithoutBalanceFungible() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // create spender and approve to it some tokens + var spenderKey = PrivateKey.generateED25519(); + var spenderAccountID = EntityHelper.createAccount(testEnv, spenderKey, -1); + + // create sender + var senderKey = PrivateKey.generateED25519(); + var senderAccountID = EntityHelper.createAccount(testEnv, senderKey, -1); + + // transfer ft to sender + new TransferTransaction() + .addTokenTransfer(tokenID, testEnv.operatorId, -amount) + .addTokenTransfer(tokenID, senderAccountID, amount) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + // approve allowance to the spender + new AccountAllowanceApproveTransaction() + .approveTokenAllowance(tokenID, senderAccountID, spenderAccountID, amount) + .freezeWith(testEnv.client) + .sign(senderKey) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + // airdrop the tokens from the sender to the spender via approval + // fails with NOT_SUPPORTED + assertThatExceptionOfType(PrecheckStatusException.class).isThrownBy(() -> { + new TokenAirdropTransaction() + .addTokenTransfer(tokenID, spenderAccountID, amount) + .addApprovedTokenTransfer(tokenID, spenderAccountID, -amount) + .setTransactionId(TransactionId.generate(spenderAccountID)) + .freezeWith(testEnv.client) + .sign(spenderKey) + .execute(testEnv.client) + .getReceipt(testEnv.client); + }).withMessageContaining(Status.NOT_SUPPORTED.toString()); + + testEnv.close(); + } + + @Test + @DisplayName("Cannot airdrop nft with no balance") + void cannotAirdropTokensWithAllowanceAndWithoutBalanceNFT() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // create nft + 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 spender and approve to it some tokens + var spenderKey = PrivateKey.generateED25519(); + var spenderAccountID = EntityHelper.createAccount(testEnv, spenderKey, -1); + + // create sender + var senderKey = PrivateKey.generateED25519(); + var senderAccountID = EntityHelper.createAccount(testEnv, senderKey, -1); + + // transfer ft to sender + new TransferTransaction() + .addNftTransfer(nftID.nft(nftSerials.get(0)), testEnv.operatorId, senderAccountID) + .addNftTransfer(nftID.nft(nftSerials.get(1)), testEnv.operatorId, senderAccountID) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + // approve allowance to the spender + new AccountAllowanceApproveTransaction() + .approveTokenNftAllowance(nftID.nft(nftSerials.get(0)), senderAccountID, spenderAccountID) + .approveTokenNftAllowance(nftID.nft(nftSerials.get(1)), senderAccountID, spenderAccountID) + .freezeWith(testEnv.client) + .sign(senderKey) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + // airdrop the tokens from the sender to the spender via approval + // fails with NOT_SUPPORTED + assertThatExceptionOfType(PrecheckStatusException.class).isThrownBy(() -> { + new TokenAirdropTransaction() + .addApprovedNftTransfer(nftID.nft(nftSerials.get(0)), senderAccountID, spenderAccountID) + .addApprovedNftTransfer(nftID.nft(nftSerials.get(1)), senderAccountID, spenderAccountID) + .setTransactionId(TransactionId.generate(spenderAccountID)) + .freezeWith(testEnv.client) + .sign(spenderKey) + .execute(testEnv.client) + .getReceipt(testEnv.client); + }).withMessageContaining(Status.NOT_SUPPORTED.toString()); + + testEnv.close(); + } + + @Test + @DisplayName("Cannot airdrop with invalid body") + void cannotAirdropTokensWithInvalidBody() throws Exception { + var testEnv = new IntegrationTestEnv(1).useThrowawayAccount(); + + // airdrop with no tokenID or NftID + // fails with EMPTY_TOKEN_TRANSFER_BODY + assertThatExceptionOfType(PrecheckStatusException.class).isThrownBy(() -> { + new TokenAirdropTransaction() + .execute(testEnv.client) + .getReceipt(testEnv.client); + }).withMessageContaining(Status.EMPTY_TOKEN_TRANSFER_BODY.toString()); + + // create fungible token + var tokenID = EntityHelper.createFungibleToken(testEnv, 3); + + // airdrop with invalid transfers + // fails with INVALID_TRANSACTION_BODY + assertThatExceptionOfType(PrecheckStatusException.class).isThrownBy(() -> { + new TokenAirdropTransaction() + .addTokenTransfer(tokenID, testEnv.operatorId, 100) + .addTokenTransfer(tokenID, testEnv.operatorId, 100) + .execute(testEnv.client) + .getReceipt(testEnv.client); + }).withMessageContaining(Status.INVALID_TRANSACTION_BODY.toString()); + + testEnv.close(); + } +}