diff --git a/src/sdk/main/include/Transaction.h b/src/sdk/main/include/Transaction.h index e0479dc5..97f0e4ac 100644 --- a/src/sdk/main/include/Transaction.h +++ b/src/sdk/main/include/Transaction.h @@ -35,6 +35,13 @@ class TransactionResponse; namespace Hiero { + +struct SignableNodeTransactionBodyBytes { + AccountId nodeId; + std::vector body; + TransactionId transactionId; +}; + /** * Base class for all transactions that can be submitted to a Hiero network. * @@ -154,6 +161,22 @@ class Transaction virtual SdkRequestType& addSignature(const std::shared_ptr& publicKey, const std::vector& signature); + /** + * Add a signature to this Transaction for a specific transaction ID and node account ID. This is useful for signing + * chunked transactions that have multiple transaction IDs. + * + * @param publicKey The public key that is adding a signature. + * @param signature The signature to add. + * @param transactionId The ID of the transaction to which to add the signature. + * @param nodeId The ID of the node account for the transaction to which to add the signature. + * @return A reference to this derived Transaction object with the newly-added signature. + * @throws IllegalStateException If this Transaction is not frozen. + */ + virtual SdkRequestType& addSignature(const std::shared_ptr& publicKey, + const std::vector& signature, + const TransactionId& transactionId, + const AccountId& nodeId); + /** * Get the signatures of each potential Transaction protobuf object this Transaction may send. * @@ -330,6 +353,16 @@ class Transaction */ [[nodiscard]] size_t getTransactionBodySize() const; + /** + * Get a list of signable node transaction body bytes for each signed transaction in the transaction list. + * The NodeId represents the node that this transaction is signed for. + * The TransactionId is useful for signing chunked transactions like FileAppendTransaction, since they can have multiple transaction ids. + * @return A vector of SignableNodeTransactionBodyBytes, one for each signed transaction. + * @throws IllegalStateException If this Transaction is not frozen. + * @throws std::runtime_error If parsing the TransactionBody fails. + */ + [[nodiscard]] std::vector getSignableNodeBodyBytesList() const; + protected: /** * Dummy transaction and account IDs used to assist in deserializing incomplete Transactions. diff --git a/src/sdk/main/src/Transaction.cc b/src/sdk/main/src/Transaction.cc index 52864547..9f585303 100644 --- a/src/sdk/main/src/Transaction.cc +++ b/src/sdk/main/src/Transaction.cc @@ -431,7 +431,7 @@ SdkRequestType& Transaction::batchify(const Client& client, cons Executable::setNodeAccountIds( { DUMMY_ACCOUNT_ID }); - // Sign the transaction with the client’s operator, if applicable + // Sign the transaction with the client's operator, if applicable if (client.getOperatorAccountId().has_value()) { signWithOperator(client); @@ -495,6 +495,71 @@ SdkRequestType& Transaction::addSignature(const std::shared_ptr< return static_cast(*this); } +//----- +template +SdkRequestType& Transaction::addSignature(const std::shared_ptr& publicKey, + const std::vector& signature, + const TransactionId& transactionId, + const AccountId& nodeId) +{ + // A signature can only be added to frozen Transactions. + if (!isFrozen()) + { + throw IllegalStateException("Adding a signature to a Transaction requires the Transaction to be frozen"); + } + + bool signedAtLeastOne = false; + for (auto& signedTx : mImpl->mSignedTransactions) + { + proto::TransactionBody txBody; + if (!txBody.ParseFromString(signedTx.bodybytes())) + { + // This should not happen with a correctly constructed transaction. + throw std::runtime_error("Failed to parse TransactionBody from SignedTransaction body bytes."); + } + + if (txBody.has_transactionid() && txBody.has_nodeaccountid() && + TransactionId::fromProtobuf(txBody.transactionid()) == transactionId && + AccountId::fromProtobuf(txBody.nodeaccountid()) == nodeId) + { + // Check if this public key has already signed this specific SignedTransaction + bool alreadySigned = false; + const std::string pkBytesStr = internal::Utilities::byteVectorToString(publicKey->toBytesRaw()); + for (const auto& sigPair : signedTx.sigmap().sigpair()) + { + if (sigPair.pubkeyprefix() == pkBytesStr) + { + alreadySigned = true; + break; + } + } + + if (!alreadySigned) + { + *signedTx.mutable_sigmap()->add_sigpair() = *publicKey->toSignaturePairProtobuf(signature); + signedAtLeastOne = true; + } + } + } + + if (signedAtLeastOne) + { + // Adding a signature will require all Transaction protobuf objects to be regenerated. + mImpl->mTransactions.clear(); + mImpl->mTransactions.resize(mImpl->mSignedTransactions.size()); + + // If this key hasn't been seen before for this whole transaction, add it to the list of signatories so it's known + // this key is a signer. + if (!keyAlreadySigned(publicKey)) + { + mImpl->mSignatories.emplace(publicKey, std::function(const std::vector&)>()); + mImpl->mPrivateKeys.emplace(publicKey, nullptr); + } + } + + return static_cast(*this); +} + //----- template std::map, std::vector>> @@ -1379,6 +1444,33 @@ SdkRequestType& Transaction::signInternal( return static_cast(*this); } +//----- +template +std::vector Transaction::getSignableNodeBodyBytesList() const +{ + if (!isFrozen()) { + throw IllegalStateException("Transaction must be frozen in order to get signable node body bytes."); + } + + std::vector result; + result.reserve(mImpl->mSignedTransactions.size()); + + for (const auto& signedTx : mImpl->mSignedTransactions) { + proto::TransactionBody body; + if (!body.ParseFromString(signedTx.bodybytes())) { + throw std::runtime_error("Failed to parse TransactionBody from SignedTransaction body bytes."); + } + AccountId nodeId = body.has_nodeaccountid() ? AccountId::fromProtobuf(body.nodeaccountid()) : AccountId(); + TransactionId transactionId = body.has_transactionid() ? TransactionId::fromProtobuf(body.transactionid()) : TransactionId(); + result.push_back(SignableNodeTransactionBodyBytes{ + nodeId, + internal::Utilities::stringToByteVector(signedTx.bodybytes()), + transactionId + }); + } + return result; +} + /** * Explicit template instantiations. */ diff --git a/src/sdk/tests/unit/TransactionUnitTests.cc b/src/sdk/tests/unit/TransactionUnitTests.cc index 73794dca..e113a659 100644 --- a/src/sdk/tests/unit/TransactionUnitTests.cc +++ b/src/sdk/tests/unit/TransactionUnitTests.cc @@ -2819,4 +2819,140 @@ TEST_F(TransactionUnitTests, GetTransactionBodySizeForBigAndSmallData) // Then ASSERT_GT(transactionBigBodySize, transactionSmallBodySize); +} + +//----- +TEST_F(TransactionUnitTests, GetSignableNodeBodyBytesListWorksForFrozenTransaction) +{ + // Given + AccountId nodeId(1ULL, 2ULL, 3ULL); + TransactionId txId = TransactionId::generate(AccountId(7ULL, 8ULL, 9ULL)); + AccountCreateTransaction transaction; + transaction.setNodeAccountIds({ nodeId }); + transaction.setTransactionId(txId); + transaction.freeze(); + + // When + std::vector result; + ASSERT_NO_THROW(result = transaction.getSignableNodeBodyBytesList()); + + // Then + ASSERT_EQ(result.size(), 1U); + EXPECT_EQ(result[0].nodeId, nodeId); + EXPECT_EQ(result[0].transactionId, txId); + EXPECT_FALSE(result[0].body.empty()); +} + +//----- +TEST_F(TransactionUnitTests, GetSignableNodeBodyBytesListThrowsIfNotFrozen) +{ + // Given + AccountCreateTransaction transaction; + // Not frozen + + // When / Then + EXPECT_THROW(transaction.getSignableNodeBodyBytesList(), IllegalStateException); +} + +//----- +TEST_F(TransactionUnitTests, AddSignatureV2AddsSignatureToCorrectChunk) +{ + // Given + AccountId nodeId1(1ULL, 2ULL, 3ULL); + AccountId nodeId2(4ULL, 5ULL, 6ULL); + TransactionId txId1 = TransactionId::generate(AccountId(7ULL, 8ULL, 9ULL)); + TransactionId txId2 = TransactionId::generate(AccountId(10ULL, 11ULL, 12ULL)); + std::shared_ptr privKey = ED25519PrivateKey::generatePrivateKey(); + std::shared_ptr pubKey = privKey->getPublicKey(); + std::vector signature = privKey->sign({std::byte{0x01}, std::byte{0x02}}); + + // Build proto objects for each chunk + proto::TransactionBody txBody1, txBody2; + txBody1.set_allocated_cryptocreateaccount(new proto::CryptoCreateTransactionBody()); + txBody2.set_allocated_cryptocreateaccount(new proto::CryptoCreateTransactionBody()); + txBody1.set_allocated_transactionid(txId1.toProtobuf().release()); + txBody2.set_allocated_transactionid(txId2.toProtobuf().release()); + + proto::SignedTransaction signedTx1, signedTx2; + signedTx1.set_bodybytes(txBody1.SerializeAsString()); + signedTx2.set_bodybytes(txBody2.SerializeAsString()); + + proto::Transaction protoTx1, protoTx2; + protoTx1.set_signedtransactionbytes(signedTx1.SerializeAsString()); + protoTx2.set_signedtransactionbytes(signedTx2.SerializeAsString()); + + std::map> transactions; + transactions[txId1][nodeId1] = protoTx1; + transactions[txId2][nodeId2] = protoTx2; + + AccountCreateTransaction tx(transactions); + tx.freeze(); + + // When: Add signature to chunk with txId2 and nodeId2 + EXPECT_NO_THROW(tx.addSignature(pubKey, signature, txId2, nodeId2)); + + // Then: Only the second chunk should have the signature + // Use getSignatures() to check + auto signatures = tx.getSignatures(); + // nodeId1 chunk should not have the signature + bool found1 = false; + if (signatures.count(nodeId1)) { + for (const auto& [key, sig] : signatures[nodeId1]) { + if (key && pubKey && key->toBytesRaw() == pubKey->toBytesRaw()) found1 = true; + } + } + // nodeId2 chunk should have the signature + bool found2 = false; + if (signatures.count(nodeId2)) { + for (const auto& [key, sig] : signatures[nodeId2]) { + if (key && pubKey && key->toBytesRaw() == pubKey->toBytesRaw()) found2 = true; + } + } + EXPECT_FALSE(found1); + EXPECT_TRUE(found2); +} + +//----- +TEST_F(TransactionUnitTests, AddSignatureV2ThrowsIfNotFrozen) +{ + // Given + AccountCreateTransaction tx; + std::shared_ptr privKey = ED25519PrivateKey::generatePrivateKey(); + std::shared_ptr pubKey = privKey->getPublicKey(); + std::vector signature = privKey->sign({std::byte{0x01}, std::byte{0x02}}); + TransactionId txId = TransactionId::generate(AccountId(1ULL)); + AccountId nodeId(2ULL); + + // When / Then + EXPECT_THROW(tx.addSignature(pubKey, signature, txId, nodeId), IllegalStateException); +} + +//----- +TEST_F(TransactionUnitTests, AddSignatureV2DoesNotDuplicateSignature) +{ + // Given + AccountId nodeId(1ULL, 2ULL, 3ULL); + TransactionId txId = TransactionId::generate(AccountId(7ULL, 8ULL, 9ULL)); + std::shared_ptr privKey = ED25519PrivateKey::generatePrivateKey(); + std::shared_ptr pubKey = privKey->getPublicKey(); + std::vector signature = privKey->sign({std::byte{0x01}, std::byte{0x02}}); + AccountCreateTransaction tx; + tx.setNodeAccountIds({nodeId}); + tx.setTransactionId(txId); + tx.freeze(); + + // Add signature once + EXPECT_NO_THROW(tx.addSignature(pubKey, signature, txId, nodeId)); + // Add signature again + EXPECT_NO_THROW(tx.addSignature(pubKey, signature, txId, nodeId)); + + // Then: Only one signature should be present + auto signatures = tx.getSignatures(); + int count = 0; + if (signatures.count(nodeId)) { + for (const auto& [key, sig] : signatures[nodeId]) { + if (key && pubKey && key->toBytesRaw() == pubKey->toBytesRaw()) count++; + } + } + EXPECT_EQ(count, 1); } \ No newline at end of file