diff --git a/examples/sign_tx_body_bytes_with_hsm/main.go b/examples/sign_tx_body_bytes_with_hsm/main.go new file mode 100644 index 00000000..b8baa023 --- /dev/null +++ b/examples/sign_tx_body_bytes_with_hsm/main.go @@ -0,0 +1,228 @@ +package main + +import ( + "fmt" + "os" + "strings" + + hiero "github.com/hiero-ledger/hiero-sdk-go/v2/sdk" +) + +func main() { + var client *hiero.Client + var err error + + // Retrieving network type from environment variable HEDERA_NETWORK + client, err = hiero.ClientForName(os.Getenv("HEDERA_NETWORK")) + if err != nil { + panic(fmt.Sprintf("%v : error creating client", err)) + } + + // Retrieving operator ID from environment variable OPERATOR_ID + operatorAccountID, err := hiero.AccountIDFromString(os.Getenv("OPERATOR_ID")) + if err != nil { + panic(fmt.Sprintf("%v : error converting string to AccountID", err)) + } + + // Retrieving operator key from environment variable OPERATOR_KEY + operatorKey, err := hiero.PrivateKeyFromString(os.Getenv("OPERATOR_KEY")) + if err != nil { + panic(fmt.Sprintf("%v : error converting string to PrivateKey", err)) + } + + // Setting the client operator ID and key + client.SetOperator(operatorAccountID, operatorKey) + + defer client.Close() + + // Generate keys for sender and receiver + senderKey, err := hiero.GeneratePrivateKey() + if err != nil { + panic(fmt.Sprintf("Failed to generate sender private key: %v", err)) + } + receiverKey, err := hiero.GeneratePrivateKey() + if err != nil { + panic(fmt.Sprintf("Failed to generate receiver private key: %v", err)) + } + + // Create accounts + senderAccountResponse, err := hiero.NewAccountCreateTransaction(). + SetKeyWithoutAlias(senderKey.PublicKey()). + SetInitialBalance(hiero.NewHbar(10)). + Execute(client) + if err != nil { + panic(fmt.Sprintf("Failed to execute sender account creation transaction: %v", err)) + } + + senderAccountReceipt, err := senderAccountResponse.GetReceipt(client) + if err != nil { + panic(fmt.Sprintf("Failed to get receipt for sender account creation: %v", err)) + } + senderId := *senderAccountReceipt.AccountID + + receiverAccountResponse, err := hiero.NewAccountCreateTransaction(). + SetKeyWithoutAlias(receiverKey). + SetInitialBalance(hiero.NewHbar(1)). + Execute(client) + if err != nil { + panic(fmt.Sprintf("Failed to execute receiver account creation transaction: %v", err)) + } + + receiverAccountReceipt, err := receiverAccountResponse.GetReceipt(client) + if err != nil { + panic(fmt.Sprintf("Failed to get receipt for receiver account creation: %v", err)) + } + receiverId := *receiverAccountReceipt.AccountID + + // Single node transaction example + if err := singleNodeTransactionExample(client, senderId, receiverId, senderKey); err != nil { + panic(fmt.Sprintf("Single node transaction example failed: %v", err)) + } + + // Multi-node multi-chunk transaction example + if err := multiNodeFileTransactionExample(client, senderId, senderKey); err != nil { + panic(fmt.Sprintf("Multi-node file transaction example failed: %v", err)) + } +} + +func singleNodeTransactionExample(client *hiero.Client, senderId, receiverId hiero.AccountID, senderKey hiero.PrivateKey) error { + // Step 1 - Create and prepare transfer transaction + // Get first node from network + network := client.GetNetwork() + var nodeAccountId hiero.AccountID + for _, node := range network { + nodeAccountId = node + break + } + + // Create transfer transaction + transferTx := hiero.NewTransferTransaction(). + AddHbarTransfer(senderId, hiero.NewHbar(-1)). + AddHbarTransfer(receiverId, hiero.NewHbar(1)). + SetNodeAccountIDs([]hiero.AccountID{nodeAccountId}). + SetTransactionID(hiero.TransactionIDGenerate(senderId)) + + transferTx, err := transferTx.FreezeWith(client) + if err != nil { + return fmt.Errorf("failed to freeze transfer transaction: %w", err) + } + + // Step 2 - Get signable bytes and sign with HSM + signableList, err := transferTx.GetSignableNodeBodyBytesList() + if err != nil { + return fmt.Errorf("failed to get signable bytes list: %w", err) + } + + // Sign with HSM for each entry + for _, signable := range signableList { + signature := hsmSign(senderKey, signable.Body) + transferTx, err = transferTx.AddSignatureV2(senderKey.PublicKey(), signature, signable.TransactionID, signable.NodeID) + if err != nil { + return fmt.Errorf("failed to add signature: %w", err) + } + } + + // Step 3 - Execute transaction and get receipt + transferResponse, err := transferTx.Execute(client) + if err != nil { + return fmt.Errorf("failed to execute transfer transaction: %w", err) + } + + transferReceipt, err := transferResponse.GetReceipt(client) + if err != nil { + return fmt.Errorf("failed to get transfer receipt: %w", err) + } + + fmt.Printf("Single node transaction status: %v\n", transferReceipt.Status) + return nil +} + +func multiNodeFileTransactionExample(client *hiero.Client, senderId hiero.AccountID, senderKey hiero.PrivateKey) error { + // Step 1 - Create initial file + // Create large content for testing + bigContents := strings.Repeat("Lorem ipsum dolor sit amet. ", 1000) + + // Create file transaction + fileCreateTx := hiero.NewFileCreateTransaction(). + SetKeys(senderKey.PublicKey()). + SetContents([]byte("[e2e::FileCreateTransaction]")). + SetMaxTransactionFee(hiero.NewHbar(5)) + + fileCreateTx, err := fileCreateTx.FreezeWith(client) + if err != nil { + return fmt.Errorf("failed to freeze file create transaction: %w", err) + } + + fileCreateResponse, err := fileCreateTx.Sign(senderKey).Execute(client) + if err != nil { + return fmt.Errorf("failed to execute file create transaction: %w", err) + } + + fileCreateReceipt, err := fileCreateResponse.GetReceipt(client) + if err != nil { + return fmt.Errorf("failed to get file create receipt: %w", err) + } + + fileId := *fileCreateReceipt.FileID + fmt.Printf("Created file with ID: %v\n", fileId) + + // Step 2 - Prepare file append transaction + fileAppendTx := hiero.NewFileAppendTransaction(). + SetFileID(fileId). + SetContents([]byte(bigContents)). + SetMaxTransactionFee(hiero.NewHbar(5)). + SetTransactionID(hiero.TransactionIDGenerate(senderId)) + + fileAppendTx, err = fileAppendTx.FreezeWith(client) + if err != nil { + return fmt.Errorf("failed to freeze file append transaction: %w", err) + } + + // Step 3 - Get signable bytes and sign with HSM for each node + fmt.Printf("Signing transaction with HSM for nodes: %v\n", fileAppendTx.GetNodeAccountIDs()) + + multiNodeSignableList, err := fileAppendTx.GetSignableNodeBodyBytesList() + if err != nil { + return fmt.Errorf("failed to get signable bytes list: %w", err) + } + + // Sign with HSM for each entry + for _, signable := range multiNodeSignableList { + signature := hsmSign(senderKey, signable.Body) + fileAppendTx, err = fileAppendTx.AddSignatureV2(senderKey.PublicKey(), signature, signable.TransactionID, signable.NodeID) + if err != nil { + return fmt.Errorf("failed to add signature: %w", err) + } + } + + // Step 4 - Execute transaction and verify results + fileAppendResponse, err := fileAppendTx.Execute(client) + if err != nil { + return fmt.Errorf("failed to execute file append transaction: %w", err) + } + + fileAppendReceipt, err := fileAppendResponse.GetReceipt(client) + if err != nil { + return fmt.Errorf("failed to get file append receipt: %w", err) + } + + fmt.Printf("Multi-node file append transaction status: %v\n", fileAppendReceipt.Status) + + // Step 5 - Verify file contents + contents, err := hiero.NewFileContentsQuery(). + SetFileID(fileId). + Execute(client) + if err != nil { + return fmt.Errorf("failed to query file contents: %w", err) + } + + fmt.Printf("File content length according to FileContentsQuery: %d\n", len(contents)) + return nil +} + +// hsmSign simulates signing with an HSM. +// In a real implementation, this would use actual HSM SDK logic. +func hsmSign(key hiero.PrivateKey, bodyBytes []byte) []byte { + // This is a placeholder that simulates HSM signing + return key.Sign(bodyBytes) +} diff --git a/sdk/crypto.go b/sdk/crypto.go index 9bbcf2f5..700222ba 100644 --- a/sdk/crypto.go +++ b/sdk/crypto.go @@ -869,6 +869,7 @@ func (pk PublicKey) _ToSignaturePairProtobuf(signature []byte) *services.Signatu // SignTransaction signes the transaction and adds the signature to the transaction func (sk PrivateKey) SignTransaction(tx TransactionInterface) ([]byte, error) { baseTx := tx.getBaseTransaction() + // TODO: call tx.addSignature if sk.ecdsaPrivateKey != nil { b, err := sk.ecdsaPrivateKey._SignTransaction(baseTx) diff --git a/sdk/file_append_transaction_e2e_test.go b/sdk/file_append_transaction_e2e_test.go index a432eb34..c902d9d3 100644 --- a/sdk/file_append_transaction_e2e_test.go +++ b/sdk/file_append_transaction_e2e_test.go @@ -62,6 +62,62 @@ func TestIntegrationFileAppendTransactionCanExecute(t *testing.T) { } +func TestIntegrationFileAppendTransactionSignForMultipleNodes(t *testing.T) { + t.Parallel() + env := NewIntegrationTestEnv(t) + defer CloseIntegrationTestEnv(env, nil) + + newKey, err := PrivateKeyGenerateEd25519() + require.NoError(t, err) + + tx, err := NewFileCreateTransaction(). + SetKeys(newKey.PublicKey()). + SetNodeAccountIDs(env.NodeAccountIDs). + SetContents([]byte("Hello")). + SetTransactionMemo("go sdk e2e tests"). + FreezeWith(env.Client) + require.NoError(t, err) + + tx.Sign(newKey) + + resp, err := tx.Execute(env.Client) + require.NoError(t, err) + + receipt, err := resp.SetValidateStatus(true).GetReceipt(env.Client) + require.NoError(t, err) + + fileID := *receipt.FileID + assert.NotNil(t, fileID) + + tx1, err := NewFileAppendTransaction(). + SetFileID(fileID). + SetContents([]byte(LARGE_SMART_CONTRACT_BYTECODE)). + FreezeWith(env.Client) + require.NoError(t, err) + + signableBodyList, err := tx1.GetSignableNodeBodyBytesList() + require.NoError(t, err) + for _, signableBody := range signableBodyList { + signature := newKey.Sign(signableBody.Body) + tx1, err = tx1.AddSignatureV2(newKey.PublicKey(), signature, signableBody.TransactionID, signableBody.NodeID) + require.NoError(t, err) + } + + resp, err = tx1.Execute(env.Client) + require.NoError(t, err) + + _, err = resp.SetValidateStatus(true).GetReceipt(env.Client) + require.NoError(t, err) + + contents, err := NewFileContentsQuery(). + SetFileID(fileID). + SetNodeAccountIDs([]AccountID{resp.NodeID}). + Execute(env.Client) + require.NoError(t, err) + + assert.Equal(t, len(LARGE_SMART_CONTRACT_BYTECODE+"Hello"), len(contents)) +} + func TestIntegrationFileAppendTransactionNoFileID(t *testing.T) { t.Parallel() env := NewIntegrationTestEnv(t) diff --git a/sdk/transaction.go b/sdk/transaction.go index 6ed9cfd6..9e7a7f10 100644 --- a/sdk/transaction.go +++ b/sdk/transaction.go @@ -1050,9 +1050,50 @@ func (tx *Transaction[T]) GetTransactionID() TransactionID { return TransactionID{} } +type SignableNodeTransactionBodyBytes struct { + NodeID AccountID + Body []byte + TransactionID TransactionID +} + +// GetSignableNodeBodyBytesList returns a list of SignableNodeTransactionBodyBytes objects 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 chuncked transactions like FileAppendTransaction, since they can have multiple transaction ids. +func (tx *Transaction[T]) GetSignableNodeBodyBytesList() ([]SignableNodeTransactionBodyBytes, error) { + if !tx.IsFrozen() { + return nil, errTransactionIsNotFrozen + } + + // the result list + signableNodeTransactionBodyBytesList := make([]SignableNodeTransactionBodyBytes, len(tx.signedTransactions.slice)) + + // iterate over the signed transactions + for i, signedTransaction := range tx.signedTransactions.slice { + signableNodeTransactionBodyBytes := signedTransaction.(*services.SignedTransaction) + body := services.TransactionBody{} + // unmarshal the body + err := protobuf.Unmarshal(signableNodeTransactionBodyBytes.GetBodyBytes(), &body) + if err != nil { + return nil, err + } + // get the node id and transaction id from the body + nodeID := _AccountIDFromProtobuf(body.NodeAccountID) + transactionID := _TransactionIDFromProtobuf(body.TransactionID) + // add the signable node transaction body bytes to the list + signableNodeTransactionBodyBytesList[i] = SignableNodeTransactionBodyBytes{ + NodeID: *nodeID, + TransactionID: transactionID, + Body: signableNodeTransactionBodyBytes.GetBodyBytes(), + } + } + return signableNodeTransactionBodyBytesList, nil +} + // SetTransactionID sets the TransactionID for this transaction. func (tx *Transaction[T]) SetTransactionID(transactionID TransactionID) T { - tx.transactionIDs._Clear()._Push(transactionID)._SetLocked(true) + deepCopied := transactionID.deepCopy() + + tx.transactionIDs._Clear()._Push(deepCopied)._SetLocked(true) return tx.childTransaction } @@ -1121,6 +1162,60 @@ func (tx *Transaction[T]) SignWith(publicKey PublicKey, signer TransactionSigner return tx.childTransaction } + +// AddSignatureV2 adds a signature to the transaction for a specific transaction id and node id. +// This is useful for signing chuncked transactions like FileAppendTransaction, since they can have multiple transaction ids. +func (tx *Transaction[T]) AddSignatureV2(publicKey PublicKey, signature []byte, transactionID TransactionID, nodeID AccountID) (T, error) { + if tx.signedTransactions._Length() == 0 { + return tx.childTransaction, nil + } + + tx.transactionIDs.locked = true + + for index := 0; index < tx.signedTransactions._Length(); index++ { + // get the signed transaction at index + temp, ok := tx.signedTransactions._Get(index).(*services.SignedTransaction) + if !ok { + return tx.childTransaction, errors.New("signed transaction is not a protobuf SignedTransaction") + } + // unmarshal the body + var body services.TransactionBody + err := protobuf.Unmarshal(temp.BodyBytes, &body) + if err != nil { + return tx.childTransaction, err + } + // get the transaction id and node id from the body + bodyTxID := _TransactionIDFromProtobuf(body.TransactionID) + bodyNodeID := _AccountIDFromProtobuf(body.NodeAccountID) + // check if the transaction id and node id match the input + if bodyTxID.String() == transactionID.String() && bodyNodeID.String() == nodeID.String() { + // check if the signature is already in the signature map + var found bool + for _, sig := range temp.SigMap.SigPair { + if bytes.Equal(sig.PubKeyPrefix, publicKey.BytesRaw()) { + found = true + continue + } + } + if found { + continue + } + // add the signature to the signature map for the correct transaction id and node id + temp.SigMap.SigPair = append( + temp.SigMap.SigPair, + publicKey._ToSignaturePairProtobuf(signature), + ) + // set the signed transaction at index to the updated signed transaction + tx.transactions = _NewLockableSlice() + tx.publicKeys = append(tx.publicKeys, publicKey) + tx.transactionSigners = append(tx.transactionSigners, nil) + tx.signedTransactions._Set(index, temp) + } + } + + return tx.childTransaction, nil +} + func (tx *Transaction[T]) AddSignature(publicKey PublicKey, signature []byte) T { tx._RequireOneNodeAccountID() diff --git a/sdk/transaction_e2e_test.go b/sdk/transaction_e2e_test.go index 9b156bac..9dd2d85c 100644 --- a/sdk/transaction_e2e_test.go +++ b/sdk/transaction_e2e_test.go @@ -286,3 +286,43 @@ func TestIntegrationTransactionFailsWhenSigningWithoutFreezing(t *testing.T) { require.ErrorContains(t, err, "transaction is not frozen") } + +func TestIntegrationTransactionAddSignatureV2(t *testing.T) { + t.Parallel() + env := NewIntegrationTestEnv(t) + defer CloseIntegrationTestEnv(env, nil) + + newKey, err := PrivateKeyGenerateEd25519() + require.NoError(t, err) + + resp, err := NewAccountCreateTransaction(). + SetKeyWithoutAlias(newKey.PublicKey()). + SetNodeAccountIDs(env.NodeAccountIDs). + Execute(env.Client) + require.NoError(t, err) + + receipt, err := resp.SetValidateStatus(true).GetReceipt(env.Client) + require.NoError(t, err) + + tx, err := NewAccountDeleteTransaction(). + SetNodeAccountIDs([]AccountID{resp.NodeID}). + SetAccountID(*receipt.AccountID). + SetTransferAccountID(env.Client.GetOperatorAccountID()). + FreezeWith(env.Client) + require.NoError(t, err) + + signableBodyList, err := tx.GetSignableNodeBodyBytesList() + require.NoError(t, err) + for _, signableBody := range signableBodyList { + signature := newKey.Sign(signableBody.Body) + tx, err = tx.AddSignatureV2(newKey.PublicKey(), signature, signableBody.TransactionID, signableBody.NodeID) + require.NoError(t, err) + } + + resp, err = tx.Execute(env.Client) + require.NoError(t, err) + + _, err = resp.SetValidateStatus(true).GetReceipt(env.Client) + require.NoError(t, err) + +} diff --git a/sdk/transaction_id.go b/sdk/transaction_id.go index d0a25e64..5d15da53 100644 --- a/sdk/transaction_id.go +++ b/sdk/transaction_id.go @@ -66,6 +66,29 @@ func (id TransactionID) GetRecord(client *Client) (TransactionRecord, error) { Execute(client) } +// Util method used to deep copy a TransactionID in order to avoid mutating the original +// since freeze in file append transaction mutates the transaction id while creating the chunked transactions +func (id TransactionID) deepCopy() TransactionID { + var copy TransactionID + + if id.AccountID != nil { + accountCopy := *id.AccountID + copy.AccountID = &accountCopy + } + if id.ValidStart != nil { + timeCopy := *id.ValidStart + copy.ValidStart = &timeCopy + } + if id.Nonce != nil { + nonceCopy := *id.Nonce + copy.Nonce = &nonceCopy + } + + copy.scheduled = id.scheduled + + return copy +} + // String returns a string representation of the TransactionID in `AccountID@ValidStartSeconds.ValidStartNanos?scheduled_bool/nonce` format func (id TransactionID) String() string { var pb *services.Timestamp diff --git a/sdk/transaction_unit_test.go b/sdk/transaction_unit_test.go index ca985905..3279b4e2 100644 --- a/sdk/transaction_unit_test.go +++ b/sdk/transaction_unit_test.go @@ -775,6 +775,426 @@ func TestUnitTransactionAttributesSerialization(t *testing.T) { } } +func TestUnitAddSignatureV2(t *testing.T) { + t.Parallel() + + client, err := _NewMockClient() + require.NoError(t, err) + client.SetLedgerID(*NewLedgerIDTestnet()) + + fileID := FileID{File: 3} + nodeAccountID1 := AccountID{Account: 3} + nodeAccountID2 := AccountID{Account: 4} + nodeAccountIDs := []AccountID{nodeAccountID1, nodeAccountID2} + + privateKey, err := PrivateKeyFromString(mockPrivateKey) + require.NoError(t, err) + + // Mock signature bytes + mockSignature := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} + + // Test Case 1: Single Node, Single Chunk + t.Run("Single Node Single Chunk", func(t *testing.T) { + transaction := NewFileAppendTransaction(). + SetFileID(fileID). + SetContents([]byte("test content")). + SetNodeAccountIDs([]AccountID{nodeAccountID1}). + SetTransactionID(testTransactionID) + + transaction.SetMaxChunks(1) + transaction.SetMaxChunkSize(2048) + + frozen, err := transaction.FreezeWith(client) + require.NoError(t, err) + + frozen, err = frozen.AddSignatureV2(privateKey.PublicKey(), mockSignature, testTransactionID, nodeAccountID1) + require.NoError(t, err) + + signs, err := frozen.GetSignatures() + require.NoError(t, err) + require.Len(t, signs, 1) + require.Contains(t, signs, nodeAccountID1) + + // Verify the signature bytes + for key := range signs[nodeAccountID1] { + require.Equal(t, mockSignature, signs[nodeAccountID1][key]) + } + }) + + // Test Case 2: Multiple Nodes, Single Chunk + t.Run("Multiple Nodes Single Chunk", func(t *testing.T) { + transaction := NewFileAppendTransaction(). + SetFileID(fileID). + SetContents([]byte("test content")). + SetNodeAccountIDs(nodeAccountIDs). + SetTransactionID(testTransactionID) + + transaction.SetMaxChunks(1) + transaction.SetMaxChunkSize(2048) + + frozen, err := transaction.FreezeWith(client) + require.NoError(t, err) + + // Add signatures for both nodes + frozen, err = frozen.AddSignatureV2(privateKey.PublicKey(), mockSignature, testTransactionID, nodeAccountID1) + require.NoError(t, err) + frozen, err = frozen.AddSignatureV2(privateKey.PublicKey(), mockSignature, testTransactionID, nodeAccountID2) + require.NoError(t, err) + + signs, err := frozen.GetSignatures() + require.NoError(t, err) + require.Len(t, signs, 2) + require.Contains(t, signs, nodeAccountID1) + require.Contains(t, signs, nodeAccountID2) + + // Verify signatures for both nodes + for _, nodeID := range nodeAccountIDs { + for key := range signs[nodeID] { + require.Equal(t, mockSignature, signs[nodeID][key]) + } + } + }) + + // Test Case 3: Multiple Nodes, Multiple Chunks + t.Run("Multiple Nodes Multiple Chunks", func(t *testing.T) { + content := make([]byte, 3000) + for i := range content { + content[i] = byte(i % 256) + } + + transaction := NewFileAppendTransaction(). + SetFileID(fileID). + SetContents(content). + SetNodeAccountIDs(nodeAccountIDs). + SetTransactionID(testTransactionID) + + transaction.SetMaxChunks(2) + transaction.SetMaxChunkSize(2048) + + frozen, err := transaction.FreezeWith(client) + require.NoError(t, err) + + // Add signatures for both nodes + frozen, err = frozen.AddSignatureV2(privateKey.PublicKey(), mockSignature, testTransactionID, nodeAccountID1) + require.NoError(t, err) + frozen, err = frozen.AddSignatureV2(privateKey.PublicKey(), mockSignature, testTransactionID, nodeAccountID2) + require.NoError(t, err) + + signs, err := frozen.GetSignatures() + require.NoError(t, err) + require.Len(t, signs, 2) + require.Contains(t, signs, nodeAccountID1) + require.Contains(t, signs, nodeAccountID2) + + // Verify each node has the correct signature + for _, nodeID := range nodeAccountIDs { + nodeSigs := signs[nodeID] + require.Len(t, nodeSigs, 1) + for key := range nodeSigs { + require.NotNil(t, key) + require.Equal(t, key.String(), privateKey.PublicKey().String()) + require.Equal(t, mockSignature, nodeSigs[key]) + } + } + }) + + // Test Case 4: Wrong Node ID + t.Run("Wrong Node ID", func(t *testing.T) { + transaction := NewFileAppendTransaction(). + SetFileID(fileID). + SetContents([]byte("test content")). + SetNodeAccountIDs(nodeAccountIDs). + SetTransactionID(testTransactionID) + + transaction.SetMaxChunks(1) + transaction.SetMaxChunkSize(2048) + + frozen, err := transaction.FreezeWith(client) + require.NoError(t, err) + + invalidNodeID := AccountID{Account: 999} + frozen, err = frozen.AddSignatureV2(privateKey.PublicKey(), mockSignature, testTransactionID, invalidNodeID) + require.NoError(t, err) + + signs, err := frozen.GetSignatures() + require.NoError(t, err) + require.NotContains(t, signs, invalidNodeID) + }) + + // Test Case 5: Wrong Transaction ID + t.Run("Wrong Transaction ID", func(t *testing.T) { + transaction := NewFileAppendTransaction(). + SetFileID(fileID). + SetContents([]byte("test content")). + SetNodeAccountIDs(nodeAccountIDs). + SetTransactionID(testTransactionID) + + transaction.SetMaxChunks(1) + transaction.SetMaxChunkSize(2048) + + frozen, err := transaction.FreezeWith(client) + require.NoError(t, err) + + invalidTxID := TransactionID{ + AccountID: &AccountID{Account: 999}, + ValidStart: &time.Time{}, + } + + frozen, err = frozen.AddSignatureV2(privateKey.PublicKey(), mockSignature, invalidTxID, nodeAccountID1) + require.NoError(t, err) + + signs, err := frozen.GetSignatures() + require.NoError(t, err) + require.NotContains(t, signs[nodeAccountID1], privateKey.PublicKey()) + }) + + // Test Case 6: Adding Same Signature Twice + t.Run("Adding Same Signature Twice", func(t *testing.T) { + transaction := NewFileAppendTransaction(). + SetFileID(fileID). + SetContents([]byte("test content")). + SetNodeAccountIDs([]AccountID{nodeAccountID1}). + SetTransactionID(testTransactionID) + + transaction.SetMaxChunks(1) + transaction.SetMaxChunkSize(2048) + + frozen, err := transaction.FreezeWith(client) + require.NoError(t, err) + + // Add signature first time + frozen, err = frozen.AddSignatureV2(privateKey.PublicKey(), mockSignature, testTransactionID, nodeAccountID1) + require.NoError(t, err) + + // Add same signature second time + frozen, err = frozen.AddSignatureV2(privateKey.PublicKey(), mockSignature, testTransactionID, nodeAccountID1) + require.NoError(t, err) + + signs, err := frozen.GetSignatures() + require.NoError(t, err) + require.Len(t, signs, 1) + require.Contains(t, signs, nodeAccountID1) + + // Verify there's only one signature + nodeSigs := signs[nodeAccountID1] + require.Len(t, nodeSigs, 1) + for key := range nodeSigs { + require.Equal(t, mockSignature, nodeSigs[key]) + } + }) +} + +func TestUnitAddSignatureV2WithEmptySignedTransactions(t *testing.T) { + t.Parallel() + + // Create a new transaction + tx := NewFileAppendTransaction() + + // Generate a test key + key, err := GeneratePrivateKey() + require.NoError(t, err) + + // Create mock signature + mockSignature := []byte{0, 1, 2, 3, 4} + + // Create test node and transaction IDs + nodeID := AccountID{Account: 3} + testTxID := TransactionID{ + AccountID: &AccountID{Account: 5}, + ValidStart: &time.Time{}, + } + + // Add signature when signedTransactions is empty + tx.AddSignatureV2(key.PublicKey(), mockSignature, testTxID, nodeID) + + // Verify no signatures were added + signs, err := tx.GetSignatures() + require.NoError(t, err) + require.Empty(t, signs) +} + +func TestUnitGetSignableNodeBodyBytesListUnfrozen(t *testing.T) { + t.Parallel() + + // Test unfrozen transaction + tx := NewTransferTransaction() + list, err := tx.GetSignableNodeBodyBytesList() + require.Error(t, err) + require.Equal(t, errTransactionIsNotFrozen, err) + require.Empty(t, list) +} + +func TestUnitGetSignableNodeBodyBytesListBasic(t *testing.T) { + t.Parallel() + + client, err := _NewMockClient() + require.NoError(t, err) + client.SetLedgerID(*NewLedgerIDTestnet()) + + nodeID := AccountID{Account: 3} + tx := NewTransferTransaction(). + SetNodeAccountIDs([]AccountID{nodeID}). + SetTransactionID(testTransactionID). + AddHbarTransfer(AccountID{Account: 2}, NewHbar(-1)). + AddHbarTransfer(AccountID{Account: 3}, NewHbar(1)) + + frozen, err := tx.FreezeWith(client) + require.NoError(t, err) + + list, err := frozen.GetSignableNodeBodyBytesList() + require.NoError(t, err) + require.NotEmpty(t, list) + require.Len(t, list, 1) // Should have one entry for our single node + + // Verify the basic contents of the list + require.Equal(t, nodeID, list[0].NodeID) + require.Equal(t, testTransactionID, list[0].TransactionID) + require.NotEmpty(t, list[0].Body) +} + +func TestUnitGetSignableNodeBodyBytesListContents(t *testing.T) { + t.Parallel() + + client, err := _NewMockClient() + require.NoError(t, err) + client.SetLedgerID(*NewLedgerIDTestnet()) + + nodeID := AccountID{Account: 3} + tx := NewTransferTransaction(). + SetNodeAccountIDs([]AccountID{nodeID}). + SetTransactionID(testTransactionID). + AddHbarTransfer(AccountID{Account: 2}, NewHbar(-1)). + AddHbarTransfer(AccountID{Account: 3}, NewHbar(1)) + + frozen, err := tx.FreezeWith(client) + require.NoError(t, err) + + list, err := frozen.GetSignableNodeBodyBytesList() + require.NoError(t, err) + + // Verify the body bytes can be unmarshalled into a valid transaction body + var body services.TransactionBody + err = protobuf.Unmarshal(list[0].Body, &body) + require.NoError(t, err) + require.NotNil(t, body.GetCryptoTransfer()) + require.Equal(t, nodeID.String(), _AccountIDFromProtobuf(body.NodeAccountID).String()) + require.Equal(t, testTransactionID.String(), _TransactionIDFromProtobuf(body.TransactionID).String()) +} + +func TestUnitGetSignableNodeBodyBytesListMultipleNodeIDs(t *testing.T) { + t.Parallel() + + client, err := _NewMockClient() + require.NoError(t, err) + client.SetLedgerID(*NewLedgerIDTestnet()) + + nodeID1 := AccountID{Account: 3} + nodeID2 := AccountID{Account: 4} + nodeIDs := []AccountID{nodeID1, nodeID2} + + tx := NewTransferTransaction(). + SetNodeAccountIDs(nodeIDs). + SetTransactionID(testTransactionID). + AddHbarTransfer(AccountID{Account: 2}, NewHbar(-1)). + AddHbarTransfer(AccountID{Account: 3}, NewHbar(1)) + + frozen, err := tx.FreezeWith(client) + require.NoError(t, err) + + list, err := frozen.GetSignableNodeBodyBytesList() + require.NoError(t, err) + require.Len(t, list, 2) // Should have two entries, one per node + + // Verify each node's entry + for i, nodeID := range nodeIDs { + require.Equal(t, nodeID, list[i].NodeID) + require.Equal(t, testTransactionID, list[i].TransactionID) + require.NotEmpty(t, list[i].Body) + + // Verify body contents + var body services.TransactionBody + err = protobuf.Unmarshal(list[i].Body, &body) + require.NoError(t, err) + require.NotNil(t, body.GetCryptoTransfer()) + require.Equal(t, nodeID.String(), _AccountIDFromProtobuf(body.NodeAccountID).String()) + } +} + +func TestUnitGetSignableNodeBodyBytesListFileAppendMultipleChunks(t *testing.T) { + t.Parallel() + + client, err := _NewMockClient() + require.NoError(t, err) + client.SetLedgerID(*NewLedgerIDTestnet()) + + nodeID1 := AccountID{Account: 3} + nodeID2 := AccountID{Account: 4} + nodeIDs := []AccountID{nodeID1, nodeID2} + + // Create content larger than chunk size to force multiple chunks + content := make([]byte, 4096) + for i := range content { + content[i] = byte(i % 256) + } + + tx := NewFileAppendTransaction(). + SetNodeAccountIDs(nodeIDs). + SetTransactionID(testTransactionID). + SetFileID(FileID{File: 5}). + SetContents(content) + + // Set small chunk size to force multiple chunks + tx.SetMaxChunkSize(2048) + + frozen, err := tx.FreezeWith(client) + require.NoError(t, err) + + list, err := frozen.GetSignableNodeBodyBytesList() + require.NoError(t, err) + require.Len(t, list, 4) // Should have 4 entries: 2 nodes * 2 chunks + + // Map to track transaction IDs per node + txIDsByNode := make(map[string]map[string]bool) + for _, nodeID := range nodeIDs { + txIDsByNode[nodeID.String()] = make(map[string]bool) + } + + // Verify each entry + for i := 0; i < len(list); i++ { + require.Contains(t, nodeIDs, list[i].NodeID) + require.NotEmpty(t, list[i].TransactionID) + require.NotEmpty(t, list[i].Body) + + nodeIDStr := list[i].NodeID.String() + txIDStr := list[i].TransactionID.String() + + // Each transaction ID should appear exactly once per node + require.False(t, txIDsByNode[nodeIDStr][txIDStr], "Duplicate transaction ID found for the same node") + txIDsByNode[nodeIDStr][txIDStr] = true + + // Verify body contents + var body services.TransactionBody + err = protobuf.Unmarshal(list[i].Body, &body) + require.NoError(t, err) + require.NotNil(t, body.GetFileAppend()) + require.Equal(t, list[i].NodeID.String(), _AccountIDFromProtobuf(body.NodeAccountID).String()) + } + + // Verify each node has the same number of unique transaction IDs + for _, nodeID := range nodeIDs { + require.Len(t, txIDsByNode[nodeID.String()], 2, "Each node should have exactly 2 unique transaction IDs") + } + + // Verify that all nodes have the same set of transaction IDs + firstNodeTxIDs := txIDsByNode[nodeID1.String()] + for _, nodeID := range nodeIDs[1:] { + nodeTxIDs := txIDsByNode[nodeID.String()] + for txID := range firstNodeTxIDs { + require.True(t, nodeTxIDs[txID], "All nodes should have the same set of transaction IDs") + } + } +} + func signSwitchCaseaSetup(t *testing.T) (PrivateKey, *Client, AccountID) { newKey, err := GeneratePrivateKey() require.NoError(t, err)