From 6f1239c10f51272d5b195b831244df505007f53e Mon Sep 17 00:00:00 2001 From: Nikita Lebedev Date: Thu, 14 Mar 2024 17:08:06 +0200 Subject: [PATCH] feat(HIP-745): Optionally send transaction data without required transaction fields (#1739) Signed-off-by: Nikita Lebedev --- .../java/TransactionSerializationExample.java | 81 +++ .../java/TransactionIntegrationTest.java | 637 +++++++++++++++++- .../hashgraph/sdk/ChunkedTransaction.java | 28 +- .../hashgraph/sdk/FileAppendTransaction.java | 21 +- .../sdk/TopicMessageSubmitTransaction.java | 21 +- .../com/hedera/hashgraph/sdk/Transaction.java | 182 +++-- 6 files changed, 873 insertions(+), 97 deletions(-) create mode 100644 examples/src/main/java/TransactionSerializationExample.java diff --git a/examples/src/main/java/TransactionSerializationExample.java b/examples/src/main/java/TransactionSerializationExample.java new file mode 100644 index 000000000..71018e8f3 --- /dev/null +++ b/examples/src/main/java/TransactionSerializationExample.java @@ -0,0 +1,81 @@ +import com.hedera.hashgraph.sdk.AccountBalanceQuery; +import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.Client; +import com.hedera.hashgraph.sdk.Hbar; +import com.hedera.hashgraph.sdk.PrivateKey; +import com.hedera.hashgraph.sdk.Transaction; +import com.hedera.hashgraph.sdk.TransactionRecord; +import com.hedera.hashgraph.sdk.TransferTransaction; +import io.github.cdimascio.dotenv.Dotenv; +import java.util.Objects; + +public class TransactionSerializationExample { + + // see `.env.sample` in the repository root for how to specify these values + // or set environment variables with the same names + private static final AccountId OPERATOR_ID = AccountId.fromString(Objects.requireNonNull(Dotenv.load().get("OPERATOR_ID"))); + private static final PrivateKey OPERATOR_KEY = PrivateKey.fromString(Objects.requireNonNull(Dotenv.load().get("OPERATOR_KEY"))); + // HEDERA_NETWORK defaults to testnet if not specified in dotenv + private static final String HEDERA_NETWORK = Dotenv.load().get("HEDERA_NETWORK", "testnet"); + + public TransactionSerializationExample() { + } + + public static void main(String[] args) throws Exception { + Client client = ClientHelper.forName(HEDERA_NETWORK); + + // Defaults the operator account ID and key such that all generated transactions will be paid for + // by this account and be signed by this key + client.setOperator(OPERATOR_ID, OPERATOR_KEY); + + AccountId recipientId = AccountId.fromString("0.0.3"); + Hbar amount = Hbar.fromTinybars(10_000); + + Hbar senderBalanceBefore = new AccountBalanceQuery() + .setAccountId(OPERATOR_ID) + .execute(client) + .hbars; + + Hbar receiptBalanceBefore = new AccountBalanceQuery() + .setAccountId(recipientId) + .execute(client) + .hbars; + + System.out.println("" + OPERATOR_ID + " balance = " + senderBalanceBefore); + System.out.println("" + recipientId + " balance = " + receiptBalanceBefore); + + var transferTransaction = new TransferTransaction() + // .addSender and .addRecipient can be called as many times as you want as long as the total sum from + // both sides is equivalent + .addHbarTransfer(OPERATOR_ID, amount.negated()); + + var transactionBytes = transferTransaction.toBytes(); + + TransferTransaction transferTransactionDeserialized = (TransferTransaction) Transaction.fromBytes(transactionBytes); + + var transactionResponse = transferTransactionDeserialized + .addHbarTransfer(recipientId, amount) + .setTransactionMemo("transfer test") + .execute(client); + + System.out.println("transaction ID: " + transactionResponse); + + TransactionRecord record = transactionResponse.getRecord(client); + + System.out.println("transferred " + amount + "..."); + + Hbar senderBalanceAfter = new AccountBalanceQuery() + .setAccountId(OPERATOR_ID) + .execute(client) + .hbars; + + Hbar receiptBalanceAfter = new AccountBalanceQuery() + .setAccountId(recipientId) + .execute(client) + .hbars; + + System.out.println("" + OPERATOR_ID + " balance = " + senderBalanceAfter); + System.out.println("" + recipientId + " balance = " + receiptBalanceAfter); + System.out.println("Transfer memo: " + record.transactionMemo); + } +} diff --git a/sdk/src/integrationTest/java/TransactionIntegrationTest.java b/sdk/src/integrationTest/java/TransactionIntegrationTest.java index dd833137f..0c741fb7a 100644 --- a/sdk/src/integrationTest/java/TransactionIntegrationTest.java +++ b/sdk/src/integrationTest/java/TransactionIntegrationTest.java @@ -3,7 +3,18 @@ import com.hedera.hashgraph.sdk.AccountCreateTransaction; import com.hedera.hashgraph.sdk.AccountDeleteTransaction; import com.hedera.hashgraph.sdk.AccountId; +import com.hedera.hashgraph.sdk.FileAppendTransaction; +import com.hedera.hashgraph.sdk.FileContentsQuery; +import com.hedera.hashgraph.sdk.FileCreateTransaction; +import com.hedera.hashgraph.sdk.FileDeleteTransaction; +import com.hedera.hashgraph.sdk.FileInfoQuery; +import com.hedera.hashgraph.sdk.Hbar; +import com.hedera.hashgraph.sdk.KeyList; import com.hedera.hashgraph.sdk.PrivateKey; +import com.hedera.hashgraph.sdk.TopicCreateTransaction; +import com.hedera.hashgraph.sdk.TopicDeleteTransaction; +import com.hedera.hashgraph.sdk.TopicInfoQuery; +import com.hedera.hashgraph.sdk.TopicMessageSubmitTransaction; import com.hedera.hashgraph.sdk.Transaction; import com.hedera.hashgraph.sdk.TransactionId; import com.hedera.hashgraph.sdk.TransferTransaction; @@ -19,6 +30,7 @@ import com.hedera.hashgraph.sdk.proto.TransactionID; import com.hedera.hashgraph.sdk.proto.TransactionList; import com.hedera.hashgraph.sdk.proto.TransferList; +import java.util.Objects; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -26,9 +38,11 @@ import java.util.Arrays; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatNoException; public class TransactionIntegrationTest { + @Test @DisplayName("transaction hash in transaction record is equal to the derived transaction hash") void transactionHashInTransactionRecordIsEqualToTheDerivedTransactionHash() throws Exception { @@ -61,9 +75,276 @@ var record = response.getRecord(testEnv.client); testEnv.close(accountId, key); } + /** + * @notice E2E-HIP-745 + * @url https://hips.hedera.com/hip/hip-745 + */ @Test - @DisplayName("transaction can be serialized into bytes, deserialized, signature added and executed") - void transactionFromToBytes() throws Exception { + @DisplayName("incomplete transaction can be serialized into bytes, deserialized and be equal to the original one") + void canSerializeDeserializeCompareFields() throws Exception { + var testEnv = new IntegrationTestEnv(1); + + var adminKey = PrivateKey.generateECDSA(); + var publicKey = adminKey.getPublicKey(); + + var accountCreateTransaction = new AccountCreateTransaction() + .setKey(publicKey) + .setInitialBalance(new Hbar(1L)); + + var expectedNodeAccountIds = accountCreateTransaction.getNodeAccountIds(); + var expectedBalance = new Hbar(1L); + + var transactionBytesSerialized = accountCreateTransaction.toBytes(); + AccountCreateTransaction accountCreateTransactionDeserialized = (AccountCreateTransaction) Transaction.fromBytes(transactionBytesSerialized); + + assertThat(expectedNodeAccountIds).isEqualTo(accountCreateTransactionDeserialized.getNodeAccountIds()); + assertThat(expectedBalance).isEqualTo(accountCreateTransactionDeserialized.getInitialBalance()); + assertThatExceptionOfType(IllegalStateException.class).isThrownBy( + accountCreateTransactionDeserialized::getTransactionId); + + testEnv.close(); + } + + /** + * @notice E2E-HIP-745 + * @url https://hips.hedera.com/hip/hip-745 + */ + @Test + @DisplayName("incomplete transaction with node account ids can be serialized into bytes, deserialized and be equal to the original one") + void canSerializeWithNodeAccountIdsDeserializeCompareFields() throws Exception { + var testEnv = new IntegrationTestEnv(1); + + var adminKey = PrivateKey.generateECDSA(); + var publicKey = adminKey.getPublicKey(); + + var nodeAccountIds = testEnv.client.getNetwork().values().stream().toList(); + + var accountCreateTransaction = new AccountCreateTransaction() + .setNodeAccountIds(nodeAccountIds) + .setKey(publicKey) + .setInitialBalance(new Hbar(1L)); + + var expectedNodeAccountIds = accountCreateTransaction.getNodeAccountIds(); + var expectedBalance = new Hbar(1L); + + var transactionBytesSerialized = accountCreateTransaction.toBytes(); + AccountCreateTransaction accountCreateTransactionDeserialized = (AccountCreateTransaction) Transaction.fromBytes(transactionBytesSerialized); + + assertThat(expectedNodeAccountIds.size()).isEqualTo(accountCreateTransactionDeserialized.getNodeAccountIds().size()); + assertThat(expectedBalance).isEqualTo(accountCreateTransactionDeserialized.getInitialBalance()); + assertThatExceptionOfType(IllegalStateException.class).isThrownBy( + accountCreateTransactionDeserialized::getTransactionId); + + testEnv.close(); + } + + /** + * @notice E2E-HIP-745 + * @url https://hips.hedera.com/hip/hip-745 + */ + @Test + @DisplayName("incomplete transaction can be serialized into bytes, deserialized and executed") + void canSerializeDeserializeAndExecuteIncompleteTransaction() throws Exception { + var testEnv = new IntegrationTestEnv(1); + + var adminKey = PrivateKey.generateECDSA(); + var publicKey = adminKey.getPublicKey(); + + var accountCreateTransaction = new AccountCreateTransaction() + .setKey(publicKey) + .setInitialBalance(new Hbar(1L)); + + var transactionBytesSerialized = accountCreateTransaction.toBytes(); + AccountCreateTransaction accountCreateTransactionDeserialized = (AccountCreateTransaction) Transaction.fromBytes( + transactionBytesSerialized); + + var txReceipt = accountCreateTransactionDeserialized + .execute(testEnv.client) + .getReceipt(testEnv.client); + + new AccountDeleteTransaction() + .setAccountId(txReceipt.accountId) + .setTransferAccountId(testEnv.client.getOperatorAccountId()) + .freezeWith(testEnv.client) + .sign(adminKey) + .execute(testEnv.client); + + testEnv.close(); + } + + /** + * @notice E2E-HIP-745 + * @url https://hips.hedera.com/hip/hip-745 + */ + @Test + @DisplayName("incomplete transaction with node account ids can be serialized into bytes, deserialized and executed") + void canSerializeDeserializeAndExecuteIncompleteTransactionWithNodeAccountIds() throws Exception { + var testEnv = new IntegrationTestEnv(1); + + var adminKey = PrivateKey.generateECDSA(); + var publicKey = adminKey.getPublicKey(); + + var nodeAccountIds = testEnv.client.getNetwork().values().stream().toList(); + + var accountCreateTransaction = new AccountCreateTransaction() + .setNodeAccountIds(nodeAccountIds) + .setKey(publicKey) + .setInitialBalance(new Hbar(1L)); + + var transactionBytesSerialized = accountCreateTransaction.toBytes(); + AccountCreateTransaction accountCreateTransactionDeserialized = (AccountCreateTransaction) Transaction.fromBytes( + transactionBytesSerialized); + + var txReceipt = accountCreateTransactionDeserialized + .execute(testEnv.client) + .getReceipt(testEnv.client); + + new AccountDeleteTransaction() + .setAccountId(txReceipt.accountId) + .setTransferAccountId(testEnv.client.getOperatorAccountId()) + .freezeWith(testEnv.client) + .sign(adminKey) + .execute(testEnv.client); + + testEnv.close(); + } + + /** + * @notice E2E-HIP-745 + * @url https://hips.hedera.com/hip/hip-745 + */ + @Test + @DisplayName("incomplete transaction can be serialized into bytes, deserialized, edited and executed") + void canSerializeDeserializeEditExecuteCompareFields() throws Exception { + var testEnv = new IntegrationTestEnv(1); + + var adminKey = PrivateKey.generateECDSA(); + var publicKey = adminKey.getPublicKey(); + + var accountCreateTransaction = new AccountCreateTransaction() + .setKey(publicKey); + + var expectedBalance = new Hbar(1L); + var nodeAccountIds = testEnv.client.getNetwork().values().stream().toList(); + + var transactionBytesSerialized = accountCreateTransaction.toBytes(); + AccountCreateTransaction accountCreateTransactionDeserialized = (AccountCreateTransaction) Transaction.fromBytes(transactionBytesSerialized); + + var txReceipt = accountCreateTransactionDeserialized + .setInitialBalance(new Hbar(1L)) + .setNodeAccountIds(nodeAccountIds) + .setTransactionId(TransactionId.generate(testEnv.client.getOperatorAccountId())) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + assertThat(expectedBalance).isEqualTo(accountCreateTransactionDeserialized.getInitialBalance()); + + new AccountDeleteTransaction() + .setAccountId(txReceipt.accountId) + .setTransferAccountId(testEnv.client.getOperatorAccountId()) + .freezeWith(testEnv.client) + .sign(adminKey) + .execute(testEnv.client); + + testEnv.close(); + } + + /** + * @notice E2E-HIP-745 + * @url https://hips.hedera.com/hip/hip-745 + */ + @Test + @DisplayName("incomplete transaction with node account ids can be serialized into bytes, deserialized, edited and executed") + void canSerializeDeserializeEditExecuteCompareFieldsIncompleteTransactionWithNodeAccountIds() throws Exception { + var testEnv = new IntegrationTestEnv(1); + + var adminKey = PrivateKey.generateECDSA(); + var publicKey = adminKey.getPublicKey(); + + var nodeAccountIds = testEnv.client.getNetwork().values().stream().toList(); + + var accountCreateTransaction = new AccountCreateTransaction() + .setNodeAccountIds(nodeAccountIds) + .setKey(publicKey); + + var expectedBalance = new Hbar(1L); + + var transactionBytesSerialized = accountCreateTransaction.toBytes(); + AccountCreateTransaction accountCreateTransactionDeserialized = (AccountCreateTransaction) Transaction.fromBytes(transactionBytesSerialized); + + var txReceipt = accountCreateTransactionDeserialized + .setInitialBalance(new Hbar(1L)) + .setTransactionId(TransactionId.generate(testEnv.client.getOperatorAccountId())) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + assertThat(expectedBalance).isEqualTo(accountCreateTransactionDeserialized.getInitialBalance()); + + new AccountDeleteTransaction() + .setAccountId(txReceipt.accountId) + .setTransferAccountId(testEnv.client.getOperatorAccountId()) + .freezeWith(testEnv.client) + .sign(adminKey) + .execute(testEnv.client); + + testEnv.close(); + } + + /** + * @notice E2E-HIP-745 + * @url https://hips.hedera.com/hip/hip-745 + */ + @Test + @DisplayName("complete frozen and signed transaction can be serialized into bytes, deserialized (x2) and executed") + void canFreezeSignSerializeDeserializeReserializeAndExecute() throws Exception { + var testEnv = new IntegrationTestEnv(1); + + var adminKey = PrivateKey.generateECDSA(); + var publicKey = adminKey.getPublicKey(); + + var evmAddress = publicKey.toEvmAddress(); + var initialBalance = new Hbar(1L); + var autoRenewPeriod = java.time.Duration.ofSeconds(2592000); + var memo = "test account memo"; + var maxAutomaticTokenAssociations = 4; + + var accountCreateTransaction = new AccountCreateTransaction() + .setKey(publicKey) + .setInitialBalance(initialBalance) + .setReceiverSignatureRequired(true) + .setAutoRenewPeriod(autoRenewPeriod) + .setAccountMemo(memo) + .setMaxAutomaticTokenAssociations(maxAutomaticTokenAssociations) + .setDeclineStakingReward(true) + .setAlias(evmAddress) + .freezeWith(testEnv.client) + .sign(adminKey); + + var transactionBytesSerialized = accountCreateTransaction.toBytes(); + AccountCreateTransaction accountCreateTransactionDeserialized = (AccountCreateTransaction) Transaction.fromBytes(transactionBytesSerialized); + + var transactionBytesReserialized = accountCreateTransactionDeserialized.toBytes(); + assertThat(transactionBytesSerialized).isEqualTo(transactionBytesReserialized); + + AccountCreateTransaction accountCreateTransactionReserialized = (AccountCreateTransaction) Transaction.fromBytes(transactionBytesReserialized); + + var txResponse = accountCreateTransactionReserialized.execute(testEnv.client); + + var accountId = txResponse.getReceipt(testEnv.client).accountId; + + new AccountDeleteTransaction() + .setAccountId(accountId) + .setTransferAccountId(testEnv.client.getOperatorAccountId()) + .freezeWith(testEnv.client) + .sign(adminKey) + .execute(testEnv.client); + + testEnv.close(); + } + + @Test + @DisplayName("complete frozen transaction can be serialized into bytes, deserialized, signature added and executed") + void canFreezeSerializeDeserializeAddSignatureAndExecute() throws Exception { var testEnv = new IntegrationTestEnv(1); var key = PrivateKey.generateED25519(); @@ -95,19 +376,365 @@ var record = response.getRecord(testEnv.client); var deleteTransaction2 = Transaction.fromBytes(updateBytes); - response = deleteTransaction2 + deleteTransaction2 .addSignature(key.getPublicKey(), sig1) .execute(testEnv.client); - response.getReceipt(testEnv.client); + testEnv.close(); + } + + /** + * @notice E2E-HIP-745 + * @url https://hips.hedera.com/hip/hip-745 + */ + @Test + @DisplayName("file append chunked transaction can be frozen, signed, serialized into bytes, deserialized and be equal to the original one") + void canFreezeSignSerializeDeserializeAndCompareFileAppendChunkedTransaction() throws Exception { + var testEnv = new IntegrationTestEnv(2); + + var privateKey = PrivateKey.generateED25519(); + + var response = new FileCreateTransaction() + .setKeys(testEnv.operatorKey) + .setContents("[e2e::FileCreateTransaction]") + .execute(testEnv.client); + + var fileId = Objects.requireNonNull(response.getReceipt(testEnv.client).fileId); + + Thread.sleep(5000); + + @Var var info = new FileInfoQuery() + .setFileId(fileId) + .execute(testEnv.client); + + assertThat(info.fileId).isEqualTo(fileId); + assertThat(info.size).isEqualTo(28); + assertThat(info.isDeleted).isFalse(); + assertThat(info.keys).isNotNull(); + assertThat(info.keys.getThreshold()).isNull(); + assertThat(info.keys).isEqualTo(KeyList.of(testEnv.operatorKey)); + + var fileAppendTransaction = new FileAppendTransaction() + .setFileId(fileId) + .setContents(Contents.BIG_CONTENTS) + .freezeWith(testEnv.client) + .sign(privateKey); + + var transactionBytesSerialized = fileAppendTransaction.toBytes(); + FileAppendTransaction fileAppendTransactionDeserialized = (FileAppendTransaction) Transaction.fromBytes(transactionBytesSerialized); + + var transactionBytesReserialized = fileAppendTransactionDeserialized.toBytes(); + assertThat(transactionBytesSerialized).isEqualTo(transactionBytesReserialized); + + testEnv.close(); + } + + /** + * @notice E2E-HIP-745 + * @url https://hips.hedera.com/hip/hip-745 + */ + @Test + @DisplayName("incomplete file append chunked transaction can be serialized into bytes, deserialized, edited and executed") + void canSerializeDeserializeExecuteFileAppendChunkedTransaction() throws Exception { + var testEnv = new IntegrationTestEnv(2); + + var response = new FileCreateTransaction() + .setKeys(testEnv.operatorKey) + .setContents("[e2e::FileCreateTransaction]") + .execute(testEnv.client); + + var fileId = Objects.requireNonNull(response.getReceipt(testEnv.client).fileId); + + Thread.sleep(5000); + + @Var var info = new FileInfoQuery() + .setFileId(fileId) + .execute(testEnv.client); + + assertThat(info.fileId).isEqualTo(fileId); + assertThat(info.size).isEqualTo(28); + assertThat(info.isDeleted).isFalse(); + assertThat(info.keys).isNotNull(); + assertThat(info.keys.getThreshold()).isNull(); + assertThat(info.keys).isEqualTo(KeyList.of(testEnv.operatorKey)); + + var fileAppendTransaction = new FileAppendTransaction() + .setFileId(fileId) + .setContents(Contents.BIG_CONTENTS); + + var transactionBytesSerialized = fileAppendTransaction.toBytes(); + FileAppendTransaction fileAppendTransactionDeserialized = (FileAppendTransaction) Transaction.fromBytes(transactionBytesSerialized); + + fileAppendTransactionDeserialized + .execute(testEnv.client) + .getReceipt(testEnv.client); + + var contents = new FileContentsQuery() + .setFileId(fileId) + .execute(testEnv.client); + + assertThat(contents.toStringUtf8()).isEqualTo("[e2e::FileCreateTransaction]" + Contents.BIG_CONTENTS); + + info = new FileInfoQuery() + .setFileId(fileId) + .execute(testEnv.client); + + assertThat(info.fileId).isEqualTo(fileId); + assertThat(info.size).isEqualTo(13522); + assertThat(info.isDeleted).isFalse(); + assertThat(info.keys).isNotNull(); + assertThat(info.keys.getThreshold()).isNull(); + assertThat(info.keys).isEqualTo(KeyList.of(testEnv.operatorKey)); + + new FileDeleteTransaction() + .setFileId(fileId) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + testEnv.close(); + } + + /** + * @notice E2E-HIP-745 + * @url https://hips.hedera.com/hip/hip-745 + */ + @Test + @DisplayName("incomplete file append chunked transaction with node account ids can be serialized into bytes, deserialized, edited and executed") + void canSerializeDeserializeExecuteIncompleteFileAppendChunkedTransactionWithNodeAccountIds() throws Exception { + var testEnv = new IntegrationTestEnv(2); + + var nodeAccountIds = testEnv.client.getNetwork().values().stream().toList(); + + var response = new FileCreateTransaction() + .setKeys(testEnv.operatorKey) + .setContents("[e2e::FileCreateTransaction]") + .execute(testEnv.client); + + var fileId = Objects.requireNonNull(response.getReceipt(testEnv.client).fileId); + + Thread.sleep(5000); + + @Var var info = new FileInfoQuery() + .setFileId(fileId) + .execute(testEnv.client); + + assertThat(info.fileId).isEqualTo(fileId); + assertThat(info.size).isEqualTo(28); + assertThat(info.isDeleted).isFalse(); + assertThat(info.keys).isNotNull(); + assertThat(info.keys.getThreshold()).isNull(); + assertThat(info.keys).isEqualTo(KeyList.of(testEnv.operatorKey)); + + var fileAppendTransaction = new FileAppendTransaction() + .setNodeAccountIds(nodeAccountIds) + .setFileId(fileId) + .setContents(Contents.BIG_CONTENTS); + + var transactionBytesSerialized = fileAppendTransaction.toBytes(); + FileAppendTransaction fileAppendTransactionDeserialized = (FileAppendTransaction) Transaction.fromBytes(transactionBytesSerialized); + + fileAppendTransactionDeserialized + .setTransactionId(TransactionId.generate(testEnv.client.getOperatorAccountId())) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + var contents = new FileContentsQuery() + .setFileId(fileId) + .execute(testEnv.client); + + assertThat(contents.toStringUtf8()).isEqualTo("[e2e::FileCreateTransaction]" + Contents.BIG_CONTENTS); + + info = new FileInfoQuery() + .setFileId(fileId) + .execute(testEnv.client); + + assertThat(info.fileId).isEqualTo(fileId); + assertThat(info.size).isEqualTo(13522); + assertThat(info.isDeleted).isFalse(); + assertThat(info.keys).isNotNull(); + assertThat(info.keys.getThreshold()).isNull(); + assertThat(info.keys).isEqualTo(KeyList.of(testEnv.operatorKey)); + + new FileDeleteTransaction() + .setFileId(fileId) + .execute(testEnv.client) + .getReceipt(testEnv.client); testEnv.close(); } + /** + * @notice E2E-HIP-745 + * @url https://hips.hedera.com/hip/hip-745 + */ + @Test + @DisplayName("topic message submit chunked transaction can be frozen, signed, serialized into bytes, deserialized and be equal to the original one") + void canFreezeSignSerializeDeserializeAndCompareTopicMessageSubmitChunkedTransaction() throws Exception { + var testEnv = new IntegrationTestEnv(2); + + var privateKey = PrivateKey.generateED25519(); + + var response = new TopicCreateTransaction() + .setAdminKey(testEnv.operatorKey) + .setTopicMemo("[e2e::TopicCreateTransaction]") + .execute(testEnv.client); + + var topicId = Objects.requireNonNull(response.getReceipt(testEnv.client).topicId); + + Thread.sleep(5000); + + @Var var info = new TopicInfoQuery() + .setTopicId(topicId) + .execute(testEnv.client); + + assertThat(info.topicId).isEqualTo(topicId); + assertThat(info.topicMemo).isEqualTo("[e2e::TopicCreateTransaction]"); + assertThat(info.sequenceNumber).isEqualTo(0); + assertThat(info.adminKey).isEqualTo(testEnv.operatorKey); + + var topicMessageSubmitTransaction = new TopicMessageSubmitTransaction() + .setTopicId(topicId) + .setMaxChunks(15) + .setMessage(Contents.BIG_CONTENTS) + .freezeWith(testEnv.client) + .sign(privateKey); + + var transactionBytesSerialized = topicMessageSubmitTransaction.toBytes(); + TopicMessageSubmitTransaction fileAppendTransactionDeserialized = (TopicMessageSubmitTransaction) Transaction.fromBytes(transactionBytesSerialized); + + var transactionBytesReserialized = fileAppendTransactionDeserialized.toBytes(); + assertThat(transactionBytesSerialized).isEqualTo(transactionBytesReserialized); + + new TopicDeleteTransaction() + .setTopicId(topicId) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + testEnv.close(); + } + + /** + * @notice E2E-HIP-745 + * @url https://hips.hedera.com/hip/hip-745 + */ + @Test + @DisplayName("incomplete topic message submit chunked transaction can be serialized into bytes, deserialized, edited and executed") + void canSerializeDeserializeExecuteIncompleteTopicMessageSubmitChunkedTransaction() throws Exception { + var testEnv = new IntegrationTestEnv(2); + + var response = new TopicCreateTransaction() + .setAdminKey(testEnv.operatorKey) + .setTopicMemo("[e2e::TopicCreateTransaction]") + .execute(testEnv.client); + + var topicId = Objects.requireNonNull(response.getReceipt(testEnv.client).topicId); + + Thread.sleep(5000); + + @Var var info = new TopicInfoQuery() + .setTopicId(topicId) + .execute(testEnv.client); + + assertThat(info.topicId).isEqualTo(topicId); + assertThat(info.topicMemo).isEqualTo("[e2e::TopicCreateTransaction]"); + assertThat(info.sequenceNumber).isEqualTo(0); + assertThat(info.adminKey).isEqualTo(testEnv.operatorKey); + + var topicMessageSubmitTransaction = new TopicMessageSubmitTransaction() + .setTopicId(topicId) + .setMaxChunks(15) + .setMessage(Contents.BIG_CONTENTS); + + var transactionBytesSerialized = topicMessageSubmitTransaction.toBytes(); + TopicMessageSubmitTransaction topicMessageSubmitTransactionDeserialized = (TopicMessageSubmitTransaction) Transaction.fromBytes(transactionBytesSerialized); + + var responses = topicMessageSubmitTransactionDeserialized.executeAll(testEnv.client); + + for (var resp : responses) { + resp.getReceipt(testEnv.client); + } + + info = new TopicInfoQuery() + .setTopicId(topicId) + .execute(testEnv.client); + + assertThat(info.topicId).isEqualTo(topicId); + assertThat(info.topicMemo).isEqualTo("[e2e::TopicCreateTransaction]"); + assertThat(info.sequenceNumber).isEqualTo(14); + assertThat(info.adminKey).isEqualTo(testEnv.operatorKey); + + new TopicDeleteTransaction() + .setTopicId(topicId) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + testEnv.close(); + } + + /** + * @notice E2E-HIP-745 + * @url https://hips.hedera.com/hip/hip-745 + */ + @Test + @DisplayName("incomplete topic message submit chunked transaction with node account ids can be serialized into bytes, deserialized, edited and executed") + void canSerializeDeserializeExecuteIncompleteTopicMessageSubmitChunkedTransactionWithNodeAccountIds() + throws Exception { + var testEnv = new IntegrationTestEnv(2); + + var nodeAccountIds = testEnv.client.getNetwork().values().stream().toList(); + + var response = new TopicCreateTransaction() + .setAdminKey(testEnv.operatorKey) + .setTopicMemo("[e2e::TopicCreateTransaction]") + .execute(testEnv.client); + + var topicId = Objects.requireNonNull(response.getReceipt(testEnv.client).topicId); + + Thread.sleep(5000); + + @Var var info = new TopicInfoQuery() + .setTopicId(topicId) + .execute(testEnv.client); + + assertThat(info.topicId).isEqualTo(topicId); + assertThat(info.topicMemo).isEqualTo("[e2e::TopicCreateTransaction]"); + assertThat(info.sequenceNumber).isEqualTo(0); + assertThat(info.adminKey).isEqualTo(testEnv.operatorKey); + + var topicMessageSubmitTransaction = new TopicMessageSubmitTransaction() + .setNodeAccountIds(nodeAccountIds) + .setTopicId(topicId) + .setMaxChunks(15) + .setMessage(Contents.BIG_CONTENTS); + + var transactionBytesSerialized = topicMessageSubmitTransaction.toBytes(); + TopicMessageSubmitTransaction topicMessageSubmitTransactionDeserialized = (TopicMessageSubmitTransaction) Transaction.fromBytes(transactionBytesSerialized); + + var responses = topicMessageSubmitTransactionDeserialized.executeAll(testEnv.client); + + for (var resp : responses) { + resp.getReceipt(testEnv.client); + } + + info = new TopicInfoQuery() + .setTopicId(topicId) + .execute(testEnv.client); + + assertThat(info.topicId).isEqualTo(topicId); + assertThat(info.topicMemo).isEqualTo("[e2e::TopicCreateTransaction]"); + assertThat(info.sequenceNumber).isEqualTo(14); + assertThat(info.adminKey).isEqualTo(testEnv.operatorKey); + + new TopicDeleteTransaction() + .setTopicId(topicId) + .execute(testEnv.client) + .getReceipt(testEnv.client); + + testEnv.close(); + } // TODO: this test has a bunch of things hard-coded into it, which is kinda dumb, but it's a good idea for a test. // Any way to fix it and bring it back? - @Disabled @Test @DisplayName("transaction can be serialized into bytes, deserialized, signature added and executed") diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/ChunkedTransaction.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/ChunkedTransaction.java index 74a32adb6..d70d31d20 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/ChunkedTransaction.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/ChunkedTransaction.java @@ -485,21 +485,23 @@ void wipeTransactionLists(int requiredChunks) { innerSignedTransactions = new ArrayList<>(requiredChunks * nodeAccountIds.size()); for (int i = 0; i < requiredChunks; i++) { - var startIndex = i * chunkSize; - @Var var endIndex = startIndex + chunkSize; + if (!transactionIds.isEmpty()) { + var startIndex = i * chunkSize; + @Var var endIndex = startIndex + chunkSize; - if (endIndex > this.data.size()) { - endIndex = this.data.size(); - } + if (endIndex > this.data.size()) { + endIndex = this.data.size(); + } - onFreezeChunk( - Objects.requireNonNull(frozenBodyBuilder).setTransactionID(transactionIds.get(i).toProtobuf()), - transactionIds.get(0).toProtobuf(), - startIndex, - endIndex, - i, - requiredChunks - ); + onFreezeChunk( + Objects.requireNonNull(frozenBodyBuilder).setTransactionID(transactionIds.get(i).toProtobuf()), + transactionIds.get(0).toProtobuf(), + startIndex, + endIndex, + i, + requiredChunks + ); + } // For each node we add a transaction with that node for (var nodeId : nodeAccountIds) { diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/FileAppendTransaction.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/FileAppendTransaction.java index ad5c8778b..8225cfe7e 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/FileAppendTransaction.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/FileAppendTransaction.java @@ -172,15 +172,20 @@ void initFromTransactionBody() { fileId = FileId.fromProtobuf(body.getFileID()); } - try { - for (var i = 0; i < innerSignedTransactions.size(); i += nodeAccountIds.isEmpty() ? 1 : nodeAccountIds.size()) { - data = data.concat( - TransactionBody.parseFrom(innerSignedTransactions.get(i).getBodyBytes()) - .getFileAppend().getContents() - ); + if (!innerSignedTransactions.isEmpty()) { + try { + for (var i = 0; i < innerSignedTransactions.size(); + i += nodeAccountIds.isEmpty() ? 1 : nodeAccountIds.size()) { + data = data.concat( + TransactionBody.parseFrom(innerSignedTransactions.get(i).getBodyBytes()) + .getFileAppend().getContents() + ); + } + } catch (InvalidProtocolBufferException exc) { + throw new IllegalArgumentException(exc.getMessage()); } - } catch (InvalidProtocolBufferException exc) { - throw new IllegalArgumentException(exc.getMessage()); + } else { + data = body.getContents(); } } diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/TopicMessageSubmitTransaction.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/TopicMessageSubmitTransaction.java index 0e5c9c332..c75ee5cde 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/TopicMessageSubmitTransaction.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/TopicMessageSubmitTransaction.java @@ -148,15 +148,20 @@ void initFromTransactionBody() { topicId = TopicId.fromProtobuf(body.getTopicID()); } - try { - for (var i = 0; i < innerSignedTransactions.size(); i += nodeAccountIds.isEmpty() ? 1 : nodeAccountIds.size()) { - data = data.concat( - TransactionBody.parseFrom(innerSignedTransactions.get(i).getBodyBytes()) - .getConsensusSubmitMessage().getMessage() - ); + if (!innerSignedTransactions.isEmpty()) { + try { + for (var i = 0; i < innerSignedTransactions.size(); + i += nodeAccountIds.isEmpty() ? 1 : nodeAccountIds.size()) { + data = data.concat( + TransactionBody.parseFrom(innerSignedTransactions.get(i).getBodyBytes()) + .getConsensusSubmitMessage().getMessage() + ); + } + } catch (InvalidProtocolBufferException exc) { + throw new IllegalArgumentException(exc.getMessage()); } - } catch (InvalidProtocolBufferException exc) { - throw new IllegalArgumentException(exc.getMessage()); + } else { + data = body.getMessage(); } } 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 5264c8101..33ee6138d 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/Transaction.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/Transaction.java @@ -31,6 +31,7 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.lang.reflect.Modifier; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -60,6 +61,16 @@ public abstract class Transaction> */ static final Duration DEFAULT_AUTO_RENEW_PERIOD = Duration.ofDays(90); + /** + * Dummy account ID used to assist in deserializing incomplete Transactions. + */ + protected static final AccountId DUMMY_ACCOUNT_ID = new AccountId(0L, 0L, 0L); + + /** + * Dummy transaction ID used to assist in deserializing incomplete Transactions. + */ + protected static final TransactionId DUMMY_TRANSACTION_ID = TransactionId.withValidStart(DUMMY_ACCOUNT_ID, Instant.EPOCH); + /** * Default transaction duration */ @@ -163,70 +174,76 @@ public abstract class Transaction> */ Transaction(LinkedHashMap> txs) throws InvalidProtocolBufferException { - var txCount = txs.keySet().size(); - var nodeCount = txs.values().iterator().next().size(); - - nodeAccountIds.ensureCapacity(nodeCount); - sigPairLists = new ArrayList<>(nodeCount * txCount); - outerTransactions = new ArrayList<>(nodeCount * txCount); - innerSignedTransactions = new ArrayList<>(nodeCount * txCount); - transactionIds.ensureCapacity(txCount); - - for (var transactionEntry : txs.entrySet()) { - transactionIds.add(transactionEntry.getKey()); - - for (var nodeEntry : transactionEntry.getValue().entrySet()) { - if (nodeAccountIds.size() != nodeCount) { - nodeAccountIds.add(nodeEntry.getKey()); + LinkedHashMap transactionMap = txs.values().iterator().next(); + if (!transactionMap.isEmpty() && transactionMap.keySet().iterator().next().equals(DUMMY_ACCOUNT_ID)) { + // If the first account ID is a dummy account ID, then only the source TransactionBody needs to be copied. + var signedTransaction = SignedTransaction.parseFrom(transactionMap.values().iterator().next().getSignedTransactionBytes()); + sourceTransactionBody = TransactionBody.parseFrom(signedTransaction.getBodyBytes()); + } else { + var txCount = txs.keySet().size(); + var nodeCount = txs.values().iterator().next().size(); + + nodeAccountIds.ensureCapacity(nodeCount); + sigPairLists = new ArrayList<>(nodeCount * txCount); + outerTransactions = new ArrayList<>(nodeCount * txCount); + innerSignedTransactions = new ArrayList<>(nodeCount * txCount); + transactionIds.ensureCapacity(txCount); + + for (var transactionEntry : txs.entrySet()) { + if (!transactionEntry.getKey().equals(DUMMY_TRANSACTION_ID)) { + transactionIds.add(transactionEntry.getKey()); } + for (var nodeEntry : transactionEntry.getValue().entrySet()) { + if (nodeAccountIds.size() != nodeCount) { + nodeAccountIds.add(nodeEntry.getKey()); + } - var transaction = SignedTransaction.parseFrom(nodeEntry.getValue().getSignedTransactionBytes()); - outerTransactions.add(nodeEntry.getValue()); - sigPairLists.add(transaction.getSigMap().toBuilder()); - innerSignedTransactions.add(transaction.toBuilder()); + var transaction = SignedTransaction.parseFrom(nodeEntry.getValue().getSignedTransactionBytes()); + outerTransactions.add(nodeEntry.getValue()); + sigPairLists.add(transaction.getSigMap().toBuilder()); + innerSignedTransactions.add(transaction.toBuilder()); - if (publicKeys.isEmpty()) { - for (var sigPair : transaction.getSigMap().getSigPairList()) { - publicKeys.add(PublicKey.fromBytes(sigPair.getPubKeyPrefix().toByteArray())); - signers.add(null); + if (publicKeys.isEmpty()) { + for (var sigPair : transaction.getSigMap().getSigPairList()) { + publicKeys.add(PublicKey.fromBytes(sigPair.getPubKeyPrefix().toByteArray())); + signers.add(null); + } } } } - } - nodeAccountIds.remove(new AccountId(0)).setLocked(true); - transactionIds.setLocked(true); - - // Verify that transaction bodies match - for (@Var int i = 0; i < txCount; i++) { - @Var TransactionBody firstTxBody = null; - for (@Var int j = 0; j < nodeCount; j++) { - int k = i * nodeCount + j; - var txBody = TransactionBody.parseFrom(innerSignedTransactions.get(k).getBodyBytes()); - if (firstTxBody == null) { - firstTxBody = txBody; - } else { - requireProtoMatches( - firstTxBody, - txBody, - new HashSet<>(List.of("NodeAccountID")), - "TransactionBody" - ); + nodeAccountIds.remove(new AccountId(0)); + + // Verify that transaction bodies match + for (@Var int i = 0; i < txCount; i++) { + @Var TransactionBody firstTxBody = null; + for (@Var int j = 0; j < nodeCount; j++) { + int k = i * nodeCount + j; + var txBody = TransactionBody.parseFrom(innerSignedTransactions.get(k).getBodyBytes()); + if (firstTxBody == null) { + firstTxBody = txBody; + } else { + requireProtoMatches( + firstTxBody, + txBody, + new HashSet<>(List.of("NodeAccountID")), + "TransactionBody" + ); + } } } + sourceTransactionBody = TransactionBody.parseFrom(innerSignedTransactions.get(0).getBodyBytes()); } - sourceTransactionBody = TransactionBody.parseFrom(innerSignedTransactions.get(0).getBodyBytes()); - setTransactionValidDuration( DurationConverter.fromProtobuf(sourceTransactionBody.getTransactionValidDuration())); setMaxTransactionFee(Hbar.fromTinybars(sourceTransactionBody.getTransactionFee())); setTransactionMemo(sourceTransactionBody.getMemo()); - // This constructor is used in fromBytes(), which means we're reconstructing - // a transaction that was frozen and then serialized via toBytes(), - // so this transaction should be constructed as frozen. - frozenBodyBuilder = sourceTransactionBody.toBuilder(); + // The presence of signatures implies the Transaction should be frozen. + if (!publicKeys.isEmpty()) { + frozenBodyBuilder = sourceTransactionBody.toBuilder(); + } } /** @@ -263,8 +280,10 @@ public static Transaction fromBytes(byte[] bytes) throws InvalidProtocolBuffe dataCase = txBody.getDataCase(); - var account = AccountId.fromProtobuf(txBody.getNodeAccountID()); - var transactionId = TransactionId.fromProtobuf(txBody.getTransactionID()); + var account = txBody.hasNodeAccountID() ? AccountId.fromProtobuf(txBody.getNodeAccountID()) + : DUMMY_ACCOUNT_ID; + var transactionId = txBody.hasTransactionID() ? TransactionId.fromProtobuf(txBody.getTransactionID()) + : DUMMY_TRANSACTION_ID; var linked = new LinkedHashMap(); linked.put(account, transaction.build()); @@ -278,8 +297,10 @@ public static Transaction fromBytes(byte[] bytes) throws InvalidProtocolBuffe dataCase = txBody.getDataCase(); } - var account = AccountId.fromProtobuf(txBody.getNodeAccountID()); - var transactionId = TransactionId.fromProtobuf(txBody.getTransactionID()); + var account = txBody.hasNodeAccountID() ? AccountId.fromProtobuf(txBody.getNodeAccountID()) + : DUMMY_ACCOUNT_ID; + var transactionId = txBody.hasTransactionID() ? TransactionId.fromProtobuf(txBody.getTransactionID()) + : DUMMY_TRANSACTION_ID; var linked = txs.containsKey(transactionId) ? Objects.requireNonNull(txs.get(transactionId)) : @@ -695,17 +716,47 @@ public final T setTransactionMemo(String memo) { * @return the byte array representation */ public byte[] toBytes() { - if (!this.isFrozen()) { - throw new IllegalStateException( - "transaction must have been frozen before conversion to bytes will be stable, try calling `freeze`"); - } + var list = TransactionList.newBuilder(); - buildAllTransactions(); + // If no nodes have been selected yet, + // the new TransactionBody can be used to build a Transaction protobuf object. + if (nodeAccountIds.isEmpty()) { + var bodyBuilder = spawnBodyBuilder(null); + if (!transactionIds.isEmpty()) { + bodyBuilder.setTransactionID(transactionIds.get(0).toProtobuf()); + } + onFreeze(bodyBuilder); - var list = TransactionList.newBuilder(); + var signedTransaction = SignedTransaction.newBuilder() + .setBodyBytes(bodyBuilder.build().toByteString()) + .build(); + + var transaction = com.hedera.hashgraph.sdk.proto.Transaction.newBuilder() + .setSignedTransactionBytes(signedTransaction.toByteString()) + .build(); - for (var transaction : outerTransactions) { list.addTransactionList(transaction); + } else { + // Generate the SignedTransaction protobuf objects if the Transaction's not frozen. + if (!this.isFrozen()) { + frozenBodyBuilder = spawnBodyBuilder(null); + if (!transactionIds.isEmpty()) { + frozenBodyBuilder.setTransactionID(transactionIds.get(0).toProtobuf()); + } + onFreeze(frozenBodyBuilder); + + int requiredChunks = getRequiredChunks(); + if (!transactionIds.isEmpty()){ + generateTransactionIds(transactionIds.get(0), requiredChunks); + } + wipeTransactionLists(requiredChunks); + } + + // Build all the Transaction protobuf objects and add them to the TransactionList protobuf object. + buildAllTransactions(); + for (var transaction : outerTransactions) { + list.addTransactionList(transaction); + } } return list.build().toByteArray(); @@ -766,8 +817,7 @@ final TransactionId getTransactionIdInternal() { */ public final TransactionId getTransactionId() { if (transactionIds.isEmpty() || !this.isFrozen()) { - throw new IllegalStateException( - "transaction must have been frozen before getting the transaction ID, try calling `freeze`"); + throw new IllegalStateException("No transaction ID generated yet. Try freezing the transaction or manually setting the transaction ID."); } return transactionIds.setLocked(true).getCurrent(); @@ -895,7 +945,7 @@ protected boolean keyAlreadySigned(PublicKey key) { public T addSignature(PublicKey publicKey, byte[] signature) { requireOneNodeAccountId(); if (!isFrozen()) { - throw new IllegalStateException("Adding signature requires transaction to be frozen"); + freeze(); } if (keyAlreadySigned(publicKey)) { @@ -946,6 +996,10 @@ protected Map> getSignaturesAtOffset(int offse * @return the list of account id and public keys */ public Map> getSignatures() { + if (!isFrozen()) { + throw new IllegalStateException("Transaction must be frozen in order to have signatures."); + } + if (publicKeys.isEmpty()) { return Collections.emptyMap(); } @@ -1113,7 +1167,9 @@ void generateTransactionIds(TransactionId initialTransactionId, int count) { * @param requiredChunks the number of required chunks */ void wipeTransactionLists(int requiredChunks) { - Objects.requireNonNull(frozenBodyBuilder).setTransactionID(getTransactionIdInternal().toProtobuf()); + if (!transactionIds.isEmpty()) { + Objects.requireNonNull(frozenBodyBuilder).setTransactionID(getTransactionIdInternal().toProtobuf()); + } outerTransactions = new ArrayList<>(nodeAccountIds.size()); sigPairLists = new ArrayList<>(nodeAccountIds.size());