diff --git a/examples/multi-node-multi-signature-remove.js b/examples/multi-node-multi-signature-remove.js new file mode 100644 index 000000000..12f1db1ae --- /dev/null +++ b/examples/multi-node-multi-signature-remove.js @@ -0,0 +1,209 @@ +import { + Client, + PrivateKey, + AccountCreateTransaction, + Hbar, + AccountId, + KeyList, + TransferTransaction, + Transaction, +} from "@hashgraph/sdk"; + +import dotenv from "dotenv"; + +dotenv.config(); + +let aliceKey; +let bobKey; + +/** + * @description Create a transaction with multiple nodes and multiple signatures + * and remove one of the signatures from the transaction then add it back + */ +async function main() { + /** + * + * Step 1: Create Client + * + */ + if ( + process.env.OPERATOR_ID == null || + process.env.OPERATOR_KEY == null || + process.env.HEDERA_NETWORK == null + ) { + throw new Error( + "Environment variables OPERATOR_ID, HEDERA_NETWORK, and OPERATOR_KEY are required.", + ); + } + + const client = Client.forName(process.env.HEDERA_NETWORK).setOperator( + AccountId.fromString(process.env.OPERATOR_ID), + PrivateKey.fromStringED25519(process.env.OPERATOR_KEY), + ); + + /** + * + * Step 2: Create keys for two users + * + */ + aliceKey = PrivateKey.generate(); + bobKey = PrivateKey.generate(); + + const keyList = new KeyList([aliceKey.publicKey, bobKey.publicKey]); + + /** + * + * Step 3: Create an account with the keyList + * + */ + const createAccountTransaction = new AccountCreateTransaction() + .setInitialBalance(new Hbar(2)) + .setKey(keyList); + + const createResponse = await createAccountTransaction.execute(client); + const createReceipt = await createResponse.getReceipt(client); + + /** + * + * Step 4: Create a transfer transaction with multiple nodes + * + */ + const transferTransaction = new TransferTransaction() + .addHbarTransfer(createReceipt.accountId, new Hbar(-1)) + .addHbarTransfer("0.0.3", new Hbar(1)) + // Set multiple nodes + .setNodeAccountIds([ + new AccountId(3), + new AccountId(4), + new AccountId(5), + ]) + .freezeWith(client); + + /** + * + * Step 5: Serialize the transaction + * & Collect multiple signatures (Uint8Array[]) from one key + * + */ + + const transferTransactionBytes = transferTransaction.toBytes(); + + const aliceSignatures = aliceKey.signTransaction(transferTransaction); + const bobSignatures = bobKey.signTransaction(transferTransaction); + + /** + * + * Step 6: Deserialize the transaction + * & Add the previously collected signatures + * + */ + const signedTransaction = Transaction.fromBytes(transferTransactionBytes); + + signedTransaction.addSignature(aliceKey.publicKey, aliceSignatures); + signedTransaction.addSignature(bobKey.publicKey, bobSignatures); + + console.log("ADDED users signatures below: \n"); + + if (Array.isArray(aliceSignatures) && Array.isArray(bobSignatures)) { + console.log( + "Alice Signatures =>", + aliceSignatures.map((aliceSig) => + PrivateKey.fromBytes(aliceSig).toStringDer(), + ), + ); + + console.log( + "Bob Signatures =>", + bobSignatures.map((bobSig) => + PrivateKey.fromBytes(bobSig).toStringDer(), + ), + ); + } + + const signaturesInTheTransactionBefore = + getAllSignaturesFromTransaction(signedTransaction); + + console.log("\n\nSignatures in the transaction: "); + console.log(signaturesInTheTransactionBefore); + + /** + * + * Step 7: Remove the signatures for Alice from the transaction + * + */ + + const removedAliceSignatures = signedTransaction.removeSignature( + aliceKey.publicKey, + ); + + console.log("\nREMOVED Alice signatures below: \n"); + + if (Array.isArray(aliceSignatures) && Array.isArray(bobSignatures)) { + console.log( + "Alice removed signatures =>", + removedAliceSignatures.map((aliceSig) => + PrivateKey.fromBytes(aliceSig).toStringDer(), + ), + ); + } + + const signaturesInTheTransactionAfter = + getAllSignaturesFromTransaction(signedTransaction); + + console.log("\n\nSignatures left in the transaction: "); + console.log(signaturesInTheTransactionAfter); + + /** + * + * Step 8: Add the removed signature back to the transaction + * + */ + signedTransaction.addSignature(aliceKey.publicKey, removedAliceSignatures); + + /** + * + * Step 9: Execute and take the receipt + * + */ + const result = await signedTransaction.execute(client); + + const receipt = await result.getReceipt(client); + + console.log(`\n \nTransaction status: ${receipt.status.toString()}`); + + client.close(); +} + +void main(); + +/** + * Extracts all signatures from a signed transaction. + * @param {Transaction} signedTransaction - The signed transaction object containing the list of signed transactions. + * @returns {string[]} An array of signatures in DER format. + */ +const getAllSignaturesFromTransaction = (signedTransaction) => { + /** @type {string[]} */ + const signatures = []; + + signedTransaction._signedTransactions.list.forEach((transaction) => { + if (transaction.sigMap?.sigPair) { + transaction.sigMap.sigPair.forEach((sigPair) => { + if (sigPair.ed25519) { + signatures.push( + PrivateKey.fromBytesED25519( + sigPair.ed25519, + ).toStringDer(), + ); + } else if (sigPair.ECDSASecp256k1) { + signatures.push( + PrivateKey.fromBytesECDSA( + sigPair.ECDSASecp256k1, + ).toStringDer(), + ); + } + }); + } + }); + + return signatures; +}; diff --git a/examples/multi-node-multi-signature-removeAll.js b/examples/multi-node-multi-signature-removeAll.js new file mode 100644 index 000000000..2e8bc6079 --- /dev/null +++ b/examples/multi-node-multi-signature-removeAll.js @@ -0,0 +1,223 @@ +import { + Client, + PrivateKey, + AccountCreateTransaction, + Hbar, + AccountId, + KeyList, + TransferTransaction, + Transaction, + PublicKey, +} from "@hashgraph/sdk"; + +import dotenv from "dotenv"; + +dotenv.config(); + +let aliceKey; +let bobKey; + +/** + * @description Create a transaction with multiple nodes and multiple signatures + * and remove all of the signatures from the transaction + */ +async function main() { + /** + * + * Step 1: Create Client + * + */ + if ( + process.env.OPERATOR_ID == null || + process.env.OPERATOR_KEY == null || + process.env.HEDERA_NETWORK == null + ) { + throw new Error( + "Environment variables OPERATOR_ID, HEDERA_NETWORK, and OPERATOR_KEY are required.", + ); + } + + const client = Client.forName(process.env.HEDERA_NETWORK).setOperator( + AccountId.fromString(process.env.OPERATOR_ID), + PrivateKey.fromStringED25519(process.env.OPERATOR_KEY), + ); + + /** + * + * Step 2: Create keys for two users + * + */ + aliceKey = PrivateKey.generate(); + bobKey = PrivateKey.generate(); + + console.log("Alice public key:", aliceKey.publicKey.toStringDer()); + console.log("Bob public key:", bobKey.publicKey.toStringDer()); + + const keyList = new KeyList([aliceKey.publicKey, bobKey.publicKey]); + + /** + * + * Step 3: Create an account with the keyList + * + */ + const createAccountTransaction = new AccountCreateTransaction() + .setInitialBalance(new Hbar(2)) + .setKey(keyList); + + const createResponse = await createAccountTransaction.execute(client); + const createReceipt = await createResponse.getReceipt(client); + + /** + * + * Step 4: Create a transfer transaction with multiple nodes + * + */ + const transferTransaction = new TransferTransaction() + .addHbarTransfer(createReceipt.accountId, new Hbar(-1)) + .addHbarTransfer("0.0.3", new Hbar(1)) + // Set multiple nodes + .setNodeAccountIds([ + new AccountId(3), + new AccountId(4), + new AccountId(5), + ]) + .freezeWith(client); + + /** + * + * Step 5: Serialize the transaction + * & Collect multiple signatures (Uint8Array[]) from one key + * + */ + + const transferTransactionBytes = transferTransaction.toBytes(); + + const aliceSignatures = aliceKey.signTransaction(transferTransaction); + const bobSignatures = bobKey.signTransaction(transferTransaction); + + /** + * + * Step 6: Deserialize the transaction + * & Add the previously collected signatures + * + */ + const signedTransaction = Transaction.fromBytes(transferTransactionBytes); + + signedTransaction.addSignature(aliceKey.publicKey, aliceSignatures); + signedTransaction.addSignature(bobKey.publicKey, bobSignatures); + + console.log("\nADDED users signatures below: \n"); + + if (Array.isArray(aliceSignatures) && Array.isArray(bobSignatures)) { + console.log( + "Alice Signatures =>", + aliceSignatures.map((aliceSig) => + PrivateKey.fromBytes(aliceSig).toStringDer(), + ), + ); + + console.log( + "Bob Signatures =>", + bobSignatures.map((bobSig) => + PrivateKey.fromBytes(bobSig).toStringDer(), + ), + ); + } + + const signaturesInTheTransactionBefore = + getAllSignaturesFromTransaction(signedTransaction); + + console.log("\n\nSignatures in the transaction: "); + console.log(signaturesInTheTransactionBefore); + + /** + * + * Step 7: Remove all signatures from the transaction and add them back + * + */ + + const allSignaturesRemoved = signedTransaction.removeAllSignatures(); + + const signaturesInTheTransactionAfter = + getAllSignaturesFromTransaction(signedTransaction); + + console.log( + "\nSignatures left in the transaction:", + signaturesInTheTransactionAfter, + ); + + /** + * Print the signatures in DER format + * @param {Uint8Array[]} signatures - The signatures to print. + * @returns {void} An array of signatures in DER format. + */ + const printSignatures = (signatures) => { + return signatures.forEach((sig) => { + console.log(PrivateKey.fromBytes(sig).toStringDer()); + }); + }; + + for (const [publicKey, signatures] of Object.entries( + allSignaturesRemoved, + )) { + // Show the signatures for Alice & Bob + console.log(`\nRemoved signatures for ${publicKey}:`); + + if (Array.isArray(signatures)) { + printSignatures(signatures); + } + + // Add the removed signatures back + signedTransaction.addSignature( + PublicKey.fromString(publicKey), + signatures, + ); + } + + /** + * + * Step 8: Execute and take the receipt + * + */ + const result = await signedTransaction.execute(client); + + const receipt = await result.getReceipt(client); + + console.log(`\n \nTransaction status: ${receipt.status.toString()}`); + + client.close(); +} + +void main(); + +/** + * Extracts all signatures from a signed transaction. + * @param {Transaction} signedTransaction - The signed transaction object containing the list of signed transactions. + * @returns {string[]} An array of signatures in DER format. + */ +const getAllSignaturesFromTransaction = (signedTransaction) => { + /** @type {string[]} */ + const signatures = []; + + signedTransaction._signedTransactions.list.forEach((transaction) => { + if (transaction.sigMap?.sigPair) { + transaction.sigMap.sigPair.forEach((sigPair) => { + if (sigPair.ed25519) { + signatures.push( + PrivateKey.fromBytesED25519( + sigPair.ed25519, + ).toStringDer(), + ); + } else if (sigPair.ECDSASecp256k1) { + signatures.push( + PrivateKey.fromBytesECDSA( + sigPair.ECDSASecp256k1, + ).toStringDer(), + ); + } + }); + } + }); + + return signatures; +}; diff --git a/examples/multi-node-multi-signature.js b/examples/multi-node-multi-signature.js new file mode 100644 index 000000000..558ba7396 --- /dev/null +++ b/examples/multi-node-multi-signature.js @@ -0,0 +1,174 @@ +import { + Client, + PrivateKey, + AccountCreateTransaction, + Hbar, + AccountId, + KeyList, + TransferTransaction, + Transaction, +} from "@hashgraph/sdk"; + +import dotenv from "dotenv"; + +dotenv.config(); + +let aliceKey; +let bobKey; + +/** + * @description Create a transaction with multiple nodes and multiple signatures + */ +async function main() { + /** + * + * Step 1: Create Client + * + */ + if ( + process.env.OPERATOR_ID == null || + process.env.OPERATOR_KEY == null || + process.env.HEDERA_NETWORK == null + ) { + throw new Error( + "Environment variables OPERATOR_ID, HEDERA_NETWORK, and OPERATOR_KEY are required.", + ); + } + + const client = Client.forName(process.env.HEDERA_NETWORK).setOperator( + AccountId.fromString(process.env.OPERATOR_ID), + PrivateKey.fromStringED25519(process.env.OPERATOR_KEY), + ); + + /** + * + * Step 2: Create keys for two users + * + */ + aliceKey = PrivateKey.generate(); + bobKey = PrivateKey.generate(); + + const keyList = new KeyList([aliceKey.publicKey, bobKey.publicKey]); + + /** + * + * Step 3: Create an account with the keyList + * + */ + const createAccountTransaction = new AccountCreateTransaction() + .setInitialBalance(new Hbar(2)) + .setKey(keyList); + + const createResponse = await createAccountTransaction.execute(client); + const createReceipt = await createResponse.getReceipt(client); + + /** + * + * Step 4: Create a transfer transaction with multiple nodes + * + */ + const transferTransaction = new TransferTransaction() + .addHbarTransfer(createReceipt.accountId, new Hbar(-1)) + .addHbarTransfer("0.0.3", new Hbar(1)) + // Set multiple nodes + .setNodeAccountIds([ + new AccountId(3), + new AccountId(4), + new AccountId(5), + ]) + .freezeWith(client); + + /** + * + * Step 5: Serialize the transaction + * & Collect multiple signatures (Uint8Array[]) from one key + * + */ + + const transferTransactionBytes = transferTransaction.toBytes(); + + const aliceSignatures = aliceKey.signTransaction(transferTransaction); + const bobSignatures = bobKey.signTransaction(transferTransaction); + + /** + * + * Step 6: Deserialize the transaction + * & Add the previously collected signatures + * + */ + const signedTransaction = Transaction.fromBytes(transferTransactionBytes); + + signedTransaction.addSignature(aliceKey.publicKey, aliceSignatures); + signedTransaction.addSignature(bobKey.publicKey, bobSignatures); + + console.log("ADDED users signatures below: \n"); + + if (Array.isArray(aliceSignatures) && Array.isArray(bobSignatures)) { + console.log( + "Alice Signatures =>", + aliceSignatures.map((aliceSig) => + PrivateKey.fromBytes(aliceSig).toStringDer(), + ), + ); + + console.log( + "Bob Signatures =>", + bobSignatures.map((bobSig) => + PrivateKey.fromBytes(bobSig).toStringDer(), + ), + ); + } + + const signaturesInTheTransactionBefore = + getAllSignaturesFromTransaction(signedTransaction); + + console.log("\n\nSignatures in the transaction: "); + console.log(signaturesInTheTransactionBefore); + + /** + * + * Step 7: Execute and take the receipt + * + */ + const result = await signedTransaction.execute(client); + + const receipt = await result.getReceipt(client); + + console.log(`\n \nTransaction status: ${receipt.status.toString()}`); + + client.close(); +} + +void main(); + +/** + * Extracts all signatures from a signed transaction. + * @param {Transaction} signedTransaction - The signed transaction object containing the list of signed transactions. + * @returns {string[]} An array of signatures in DER format. + */ +const getAllSignaturesFromTransaction = (signedTransaction) => { + /** @type {string[]} */ + const signatures = []; + + signedTransaction._signedTransactions.list.forEach((transaction) => { + if (transaction.sigMap?.sigPair) { + transaction.sigMap.sigPair.forEach((sigPair) => { + if (sigPair.ed25519) { + signatures.push( + PrivateKey.fromBytesED25519( + sigPair.ed25519, + ).toStringDer(), + ); + } else if (sigPair.ECDSASecp256k1) { + signatures.push( + PrivateKey.fromBytesECDSA( + sigPair.ECDSASecp256k1, + ).toStringDer(), + ); + } + }); + } + }); + + return signatures; +}; diff --git a/examples/multi-sig-offline.js b/examples/multi-sig-offline.js index 50eb16274..a574d07d1 100644 --- a/examples/multi-sig-offline.js +++ b/examples/multi-sig-offline.js @@ -89,7 +89,7 @@ async function main() { /** * @param {Uint8Array} transactionBytes - * @returns {Uint8Array} + * @returns {Uint8Array | Uint8Array[]} */ function user1Signs(transactionBytes) { const transaction = Transaction.fromBytes(transactionBytes); @@ -98,7 +98,7 @@ function user1Signs(transactionBytes) { /** * @param {Uint8Array} transactionBytes - * @returns {Uint8Array} + * @returns {Uint8Array | Uint8Array[]} */ function user2Signs(transactionBytes) { const transaction = Transaction.fromBytes(transactionBytes); diff --git a/src/PrivateKey.js b/src/PrivateKey.js index 41b35816e..960ed1f73 100644 --- a/src/PrivateKey.js +++ b/src/PrivateKey.js @@ -342,7 +342,9 @@ export default class PrivateKey extends Key { transaction.addSignature(this.publicKey, signatures); - return signatures; + + // Return directly Uint8Array if there is only one signature + return signatures.length === 1 ? signatures[0] : signatures; } /** diff --git a/src/transaction/Transaction.js b/src/transaction/Transaction.js index 15c97c8eb..07e249bc7 100644 --- a/src/transaction/Transaction.js +++ b/src/transaction/Transaction.js @@ -865,6 +865,81 @@ export default class Transaction extends Executable { return this; } + /** + * This method removes all signatures from the transaction based on the public key provided. + * + * @param {PublicKey} publicKey - The public key associated with the signature to remove. + * @returns {Uint8Array[]} The removed signatures. + */ + removeSignature(publicKey) { + if (!this.isFrozen()) { + this.freeze(); + } + + const publicKeyData = publicKey.toBytesRaw(); + const publicKeyHex = hex.encode(publicKeyData); + + if (!this._signerPublicKeys.has(publicKeyHex)) { + throw new Error("The public key has not signed this transaction"); + } + + /** @type {Uint8Array[]} */ + const removedSignatures = []; + + // Iterate over the signed transactions and remove matching signatures + for (const transaction of this._signedTransactions.list) { + const removedSignaturesFromTransaction = + this._removeSignaturesFromTransaction( + transaction, + publicKeyHex, + ); + + removedSignatures.push(...removedSignaturesFromTransaction); + } + + // Remove the public key from internal tracking if no signatures remain + this._signerPublicKeys.delete(publicKeyHex); + this._publicKeys = this._publicKeys.filter( + (key) => !key.equals(publicKey), + ); + + // Update transaction signers array + this._transactionSigners.pop(); + + return removedSignatures; + } + + /** + * This method clears all signatures from the transaction and returns them in a specific format. + * + * It will call collectSignatures to get the removed signatures, then clear all signatures + * from the internal tracking. + * + * @returns {{ [userPublicKey: string]: Uint8Array[] | Uint8Array }} The removed signatures in the specified format. + */ + removeAllSignatures() { + if (!this.isFrozen()) { + this.freeze(); + } + + const removedSignatures = this._collectSignaturesByPublicKey(); + + // Iterate over the signed transactions and clear all signatures + for (const transaction of this._signedTransactions.list) { + if (transaction.sigMap && transaction.sigMap.sigPair) { + // Clear all signature pairs from the transaction's signature map + transaction.sigMap.sigPair = []; + } + } + + // Clear the internal tracking of signer public keys and other relevant arrays + this._signerPublicKeys.clear(); + this._publicKeys = []; + this._transactionSigners = []; + + return removedSignatures; + } + /** * Get the current signatures on the request * @@ -1759,6 +1834,97 @@ export default class Transaction extends Executable { response, ).finish(); } + + /** + * Removes all signatures from a transaction and collects the removed signatures. + * + * @param {HashgraphProto.proto.ISignedTransaction} transaction - The transaction object to process. + * @param {string} publicKeyHex - The hexadecimal representation of the public key. + * @returns {Uint8Array[]} An array of removed signatures. + */ + _removeSignaturesFromTransaction(transaction, publicKeyHex) { + /** @type {Uint8Array[]} */ + const removedSignatures = []; + + if (!transaction.sigMap || !transaction.sigMap.sigPair) { + return []; + } + + transaction.sigMap.sigPair = transaction.sigMap.sigPair.filter( + (sigPair) => { + const shouldRemove = this._shouldRemoveSignature( + sigPair, + publicKeyHex, + ); + const signature = sigPair.ed25519 ?? sigPair.ECDSASecp256k1; + + if (shouldRemove && signature) { + removedSignatures.push(signature); + } + + return !shouldRemove; + }, + ); + + return removedSignatures; + } + + /** + * Determines whether a signature should be removed based on the provided public key. + * + * @param {HashgraphProto.proto.ISignaturePair} sigPair - The signature pair object that contains + * the public key prefix and signature to be evaluated. + * @param {string} publicKeyHex - The hexadecimal representation of the public key to compare against. + * @returns {boolean} `true` if the public key prefix in the signature pair matches the provided public key, + * indicating that the signature should be removed; otherwise, `false`. + */ + _shouldRemoveSignature = (sigPair, publicKeyHex) => { + const sigPairPublicKeyHex = hex.encode( + sigPair?.pubKeyPrefix || new Uint8Array(), + ); + + const matchesPublicKey = sigPairPublicKeyHex === publicKeyHex; + + return matchesPublicKey; + }; + + /** + * Collects all signatures from signed transactions and returns them in a format keyed by public key. + * + * @returns {{ [publicKey: PublicKey]: Uint8Array[] }} The collected signatures keyed by public key. + */ + _collectSignaturesByPublicKey() { + /** @type {{ [publicKey: string]: Uint8Array[] }} */ + const collectedSignatures = {}; + + // Iterate over the signed transactions and collect signatures + for (const transaction of this._signedTransactions.list) { + if (!(transaction.sigMap && transaction.sigMap.sigPair)) { + return []; + } + + // Collect the signatures + for (const sigPair of transaction.sigMap.sigPair) { + const signature = sigPair.ed25519 ?? sigPair.ECDSASecp256k1; + + if (!signature || !sigPair.pubKeyPrefix) { + return []; + } + + const publicKeyHex = hex.encode(sigPair.pubKeyPrefix); + + // Initialize the structure for this publicKey if it doesn't exist + if (!collectedSignatures[publicKeyHex]) { + collectedSignatures[publicKeyHex] = []; + } + + // Add the signature to the corresponding public key + collectedSignatures[publicKeyHex].push(signature); + } + } + + return collectedSignatures; + } } /** diff --git a/test/integration/PrivateKey.js b/test/integration/PrivateKey.js index dc6a2ee8b..9a2d98c55 100644 --- a/test/integration/PrivateKey.js +++ b/test/integration/PrivateKey.js @@ -4,7 +4,6 @@ import { Hbar, AccountId, KeyList, - TransferTransaction, Transaction, Status, FileAppendTransaction, @@ -21,7 +20,7 @@ describe("PrivateKey signTransaction", function () { let env, user1Key, user2Key, createdAccountId, keyList; // Setting up the environment and creating a new account with a key list - before(async () => { + before(async function () { env = await IntegrationTestEnv.new(); user1Key = PrivateKey.generate(); @@ -43,38 +42,7 @@ describe("PrivateKey signTransaction", function () { expect(createdAccountId).to.exist; }); - it("Transfer Transaction Execution with Multiple Nodes", async () => { - // Create and sign transfer transaction - const transferTransaction = new TransferTransaction() - .addHbarTransfer(createdAccountId, new Hbar(-1)) - .addHbarTransfer("0.0.3", new Hbar(1)) - .setNodeAccountIds([ - new AccountId(3), - new AccountId(4), - new AccountId(5), - ]) - .freezeWith(env.client); - - // Serialize and sign the transaction - const transferTransactionBytes = transferTransaction.toBytes(); - const user1Signatures = user1Key.signTransaction(transferTransaction); - const user2Signatures = user2Key.signTransaction(transferTransaction); - - // Deserialize the transaction and add signatures - const signedTransaction = Transaction.fromBytes( - transferTransactionBytes, - ); - signedTransaction.addSignature(user1Key.publicKey, user1Signatures); - signedTransaction.addSignature(user2Key.publicKey, user2Signatures); - - // Execute the signed transaction - const result = await signedTransaction.execute(env.client); - const receipt = await result.getReceipt(env.client); - - expect(receipt.status).to.be.equal(Status.Success); - }); - - it("File Append Transaction Execution with Multiple Nodes", async () => { + it("File Append Transaction Execution with Multiple Nodes", async function () { const operatorKey = env.operatorKey.publicKey; // Create file diff --git a/test/integration/TransactionIntegrationTest.js b/test/integration/TransactionIntegrationTest.js index a92f05902..50c6b433c 100644 --- a/test/integration/TransactionIntegrationTest.js +++ b/test/integration/TransactionIntegrationTest.js @@ -13,9 +13,12 @@ import { TransactionId, Timestamp, AccountUpdateTransaction, + KeyList, + Status, } from "../../src/exports.js"; import * as hex from "../../src/encoding/hex.js"; import IntegrationTestEnv from "./client/NodeIntegrationTestEnv.js"; +import { expect } from "chai"; describe("TransactionIntegration", function () { it("should be executable", async function () { @@ -765,4 +768,140 @@ describe("TransactionIntegration", function () { } }); }); + + describe("Transaction Signature Manipulation Flow", function () { + let env, user1Key, user2Key, createdAccountId, keyList; + + // Setting up the environment and creating a new account with a key list + before(async function () { + env = await IntegrationTestEnv.new(); + + user1Key = PrivateKey.generate(); + user2Key = PrivateKey.generate(); + keyList = new KeyList([user1Key.publicKey, user2Key.publicKey]); + + // Create account + const createAccountTransaction = new AccountCreateTransaction() + .setInitialBalance(new Hbar(2)) + .setKey(keyList); + + const createResponse = await createAccountTransaction.execute( + env.client, + ); + const createReceipt = await createResponse.getReceipt(env.client); + + createdAccountId = createReceipt.accountId; + + expect(createdAccountId).to.exist; + }); + + /** @description: example multi-node-multi-signature-remove.js */ + it("Transaction with Signature Removal and Re-addition", async function () { + // Step 1: Create and sign transfer transaction + const transferTransaction = new TransferTransaction() + .addHbarTransfer(createdAccountId, new Hbar(-1)) + .addHbarTransfer("0.0.3", new Hbar(1)) + .setNodeAccountIds([ + new AccountId(3), + new AccountId(4), + new AccountId(5), + ]) + .freezeWith(env.client); + + // Step 2: Serialize and sign the transaction + const transferTransactionBytes = transferTransaction.toBytes(); + const user1Signatures = + user1Key.signTransaction(transferTransaction); + const user2Signatures = + user2Key.signTransaction(transferTransaction); + + // Step 3: Deserialize the transaction and add signatures + const signedTransaction = Transaction.fromBytes( + transferTransactionBytes, + ); + signedTransaction.addSignature(user1Key.publicKey, user1Signatures); + signedTransaction.addSignature(user2Key.publicKey, user2Signatures); + + const getSignaturesNumberPerNode = () => + signedTransaction._signedTransactions.list[0].sigMap.sigPair + .length; + + // Test if the transaction for a node has 2 signatures + expect(getSignaturesNumberPerNode()).to.be.equal(2); + + // Step 4: Remove the signature for user1 from the transaction + signedTransaction.removeSignature(user1Key.publicKey); + + // Test if the transaction for a node has 1 signature after removal + expect(getSignaturesNumberPerNode()).to.be.equal(1); + + // Step 5: Re-add the removed signature + signedTransaction.addSignature(user1Key.publicKey, user1Signatures); + + // Test if the transaction for a node has 2 signatures after adding back the signature + expect(getSignaturesNumberPerNode()).to.be.equal(2); + + // Step 6: Execute the signed transaction + const result = await signedTransaction.execute(env.client); + const receipt = await result.getReceipt(env.client); + + // Step 7: Verify the transaction status + expect(receipt.status).to.be.equal(Status.Success); + }); + + /** @description: example multi-node-multi-signature-removeAll.js */ + it("Transaction with All Signature Removal", async function () { + // Step 1: Create and sign transfer transaction + const transferTransaction = new TransferTransaction() + .addHbarTransfer(createdAccountId, new Hbar(-1)) + .addHbarTransfer("0.0.3", new Hbar(1)) + .setNodeAccountIds([ + new AccountId(3), + new AccountId(4), + new AccountId(5), + ]) + .freezeWith(env.client); + + // Step 2: Serialize and sign the transaction + const transferTransactionBytes = transferTransaction.toBytes(); + const user1Signatures = + user1Key.signTransaction(transferTransaction); + const user2Signatures = + user2Key.signTransaction(transferTransaction); + + // Step 3: Deserialize the transaction and add signatures + const signedTransaction = Transaction.fromBytes( + transferTransactionBytes, + ); + signedTransaction.addSignature(user1Key.publicKey, user1Signatures); + signedTransaction.addSignature(user2Key.publicKey, user2Signatures); + + const getSignaturesNumberPerNode = () => + signedTransaction._signedTransactions.list[0].sigMap.sigPair + .length; + + // Test if the transaction for a node has 2 signatures + expect(getSignaturesNumberPerNode()).to.be.equal(2); + + // Step 4: Remove the signature for user1 from the transaction + signedTransaction.removeAllSignatures(); + + // Test if the transaction for a node has 0 signatures after removal + expect(getSignaturesNumberPerNode()).to.be.equal(0); + + // Step 5: Try to execute the transaction without any signatures and expect it to fail + try { + const result = await signedTransaction.execute(env.client); + await result.getReceipt(env.client); + + // If we get here, the transaction did not fail as expected + throw new Error( + "Transaction should have failed due to missing signatures", + ); + } catch (error) { + // Expect the error to be due to an invalid signature + expect(error.message).to.include("INVALID_SIGNATURE"); + } + }); + }); }); diff --git a/test/unit/PrivateKey.js b/test/unit/PrivateKey.js index 31f2a7859..b44949fad 100644 --- a/test/unit/PrivateKey.js +++ b/test/unit/PrivateKey.js @@ -7,7 +7,7 @@ import Transaction from "../../src/transaction/Transaction.js"; describe("PrivateKey signTransaction", function () { let privateKey, mockedTransaction, mockedSignature; - beforeEach(() => { + beforeEach(function () { privateKey = PrivateKey.generate(); mockedTransaction = sinon.createStubInstance(Transaction); @@ -32,7 +32,7 @@ describe("PrivateKey signTransaction", function () { const signatures = privateKey.signTransaction(mockedTransaction); // Validate that the signatures are correct - expect(signatures).to.deep.equal([mockedSignature]); + expect(signatures).to.deep.equal(mockedSignature); sinon.assert.calledWith( mockedTransaction.addSignature, @@ -59,7 +59,7 @@ describe("PrivateKey signTransaction", function () { const signatures = privateKey.signTransaction(mockedTransaction); // Validate that an empty Uint8Array was returned - expect(signatures).to.deep.equal([new Uint8Array()]); + expect(signatures).to.deep.equal(new Uint8Array()); // Ensure that the transaction's addSignature method was called with the empty signature sinon.assert.calledWith( diff --git a/test/unit/Transaction.js b/test/unit/Transaction.js index 648005461..67aa7fdee 100644 --- a/test/unit/Transaction.js +++ b/test/unit/Transaction.js @@ -316,10 +316,10 @@ describe("Transaction", function () { }); }); - describe("addSignature tests", () => { + describe("addSignature tests", function () { let transaction, mockedPublicKey, mockedSignature; - beforeEach(() => { + beforeEach(function () { transaction = new Transaction(); mockedPublicKey = sinon.createStubInstance(PublicKey); mockedSignature = new Uint8Array([4, 5, 6]); @@ -353,7 +353,7 @@ describe("Transaction", function () { transaction.freeze = sinon.spy(); }); - it("should add a single signature when one transaction is present", () => { + it("should add a single signature when one transaction is present", function () { transaction.addSignature(mockedPublicKey, mockedSignature); // Verify the signature was added correctly to sigMap.sigPair @@ -365,7 +365,7 @@ describe("Transaction", function () { sinon.assert.calledOnce(transaction.freeze); }); - it("should throw an error when adding a single signature to multiple transactions", () => { + it("should throw an error when adding a single signature to multiple transactions", function () { transaction._signedTransactions.length = 2; transaction._signedTransactions.list = [ { @@ -385,7 +385,7 @@ describe("Transaction", function () { ); }); - it("should add multiple signatures corresponding to each transaction", () => { + it("should add multiple signatures corresponding to each transaction", function () { const mockedSignatures = [ new Uint8Array([10, 11, 12]), new Uint8Array([13, 14, 15]), @@ -431,7 +431,7 @@ describe("Transaction", function () { ); }); - it("should throw an error when signature array length doesn't match the number of transactions", () => { + it("should throw an error when signature array length doesn't match the number of transactions", function () { const mismatchedSignatures = [new Uint8Array([10, 11, 12])]; transaction._signedTransactions.length = 2; transaction._signedTransactions.list = [ @@ -452,7 +452,7 @@ describe("Transaction", function () { ); }); - it("should freeze the transaction if it is not frozen", () => { + it("should freeze the transaction if it is not frozen", function () { transaction.isFrozen.returns(false); transaction._signedTransactions.length = 1; @@ -468,4 +468,235 @@ describe("Transaction", function () { sinon.assert.calledOnce(transaction.freeze); }); }); + + describe("Transaction removeSignature/removeAllSignatures methods", function () { + let key1, key2, key3; + let transaction; + + beforeEach(async function () { + const nodeAccountId = new AccountId(3); + const account = AccountId.fromString("0.0.1004"); + const validStart = new Timestamp(1451, 590); + const transactionId = new TransactionId(account, validStart); + + key1 = PrivateKey.generateED25519(); + key2 = PrivateKey.generateED25519(); + key3 = PrivateKey.generateED25519(); + + transaction = new AccountCreateTransaction() + .setInitialBalance(new Hbar(2)) + .setTransactionId(transactionId) + .setNodeAccountIds([nodeAccountId]) + .freeze(); + }); + + const signAndAddSignatures = (transaction, ...keys) => { + // Map through the keys to sign the transaction and add signatures + const signatures = keys.map((key) => { + const signature = key.signTransaction(transaction); + transaction.addSignature(key.publicKey, signature); + return signature; + }); + + return signatures; + }; + + it("should remove a specific signature", function () { + // Sign the transaction with multiple keys + const [signature1] = signAndAddSignatures( + transaction, + key1, + key2, + key3, + ); + + //Check if the transaction internal tracking of signer public keys is correct + expect(transaction._signerPublicKeys.size).to.equal(3); + expect(transaction._publicKeys.length).to.equal(3); + expect(transaction._transactionSigners.length).to.equal(3); + + // Ensure all signatures are present before removal + const signaturesBefore = transaction.getSignatures(); + expect(signaturesBefore.get(new AccountId(3)).size).to.equal(3); + + // Remove one signature + transaction.removeSignature(key1.publicKey, signature1); + + //Check if the transaction is frozen + expect(transaction.isFrozen()).to.be.true; + + //Check if the transaction internal tracking of signer public keys is correct + expect(transaction._signerPublicKeys.size).to.equal(2); + expect(transaction._publicKeys.length).to.equal(2); + expect(transaction._transactionSigners.length).to.equal(2); + + // Ensure the specific signature has been removed + const signaturesAfter = transaction.getSignatures(); + expect(signaturesAfter.get(new AccountId(3)).size).to.equal(2); + }); + + it("should clear all signatures", function () { + // Sign the transaction with multiple keys + signAndAddSignatures(transaction, key1, key2, key3); + + // Ensure all signatures are present before clearing + const signaturesBefore = transaction.getSignatures(); + expect(signaturesBefore.get(new AccountId(3)).size).to.equal(3); + + // Clear all signatures + transaction.removeAllSignatures(); + + //Check if the transaction is frozen + expect(transaction.isFrozen()).to.be.true; + + //Check if the transaction internal tracking of signer public keys is cleared + expect(transaction._signerPublicKeys.size).to.equal(0); + expect(transaction._publicKeys.length).to.equal(0); + expect(transaction._transactionSigners.length).to.equal(0); + + // Ensure all signatures have been cleared + const signaturesAfter = transaction.getSignatures(); + expect(signaturesAfter.get(new AccountId(3)).size).to.equal(0); + }); + + it("should not remove a non-existing signature", function () { + // Sign the transaction with multiple keys + signAndAddSignatures(transaction, key1, key2); + + // Attempt to remove a non-existing signature + expect(() => { + transaction.removeSignature(key3.publicKey); + }).to.throw("The public key has not signed this transaction"); + + // Ensure signatures are not affected + const signaturesAfter = transaction.getSignatures(); + expect(signaturesAfter.get(new AccountId(3)).size).to.equal(2); + }); + + it("should clear and re-sign after all signatures are cleared", function () { + // Sign the transaction with multiple keys + signAndAddSignatures(transaction, key1, key2); + + // Ensure all signatures are present before clearing + const signaturesBefore = transaction.getSignatures(); + expect(signaturesBefore.get(new AccountId(3)).size).to.equal(2); + + // Clear all signatures + transaction.removeAllSignatures(); + + // Ensure all signatures have been cleared + const signaturesAfterClear = transaction.getSignatures(); + expect(signaturesAfterClear.get(new AccountId(3)).size).to.equal(0); + + // Re-sign the transaction with a different key + const signature3 = key3.signTransaction(transaction); + transaction.addSignature(key3.publicKey, signature3); + + // Ensure only one signature exists after re-signing + const signaturesAfterResign = transaction.getSignatures(); + expect(signaturesAfterResign.get(new AccountId(3)).size).to.equal( + 1, + ); + }); + + it("should return the removed signature in Uint8Array format when removing a specific signature", function () { + // Sign the transaction with multiple keys + const [signature1] = signAndAddSignatures( + transaction, + key1, + key2, + key3, + ); + + // Remove one signature and capture the returned value + const removedSignatures = transaction.removeSignature( + key1.publicKey, + signature1, + ); + + // Check the format of the returned value + expect(removedSignatures).to.be.an("array"); + expect(removedSignatures.length).to.equal(1); + expect(removedSignatures[0]).to.be.instanceOf(Uint8Array); + + // Ensure the returned signature is the one that was removed + expect(removedSignatures[0]).to.deep.equal(signature1); + }); + + it("should return the signatures for a public key in Uint8Array format when only the public key is provided", function () { + // Sign the transaction with multiple keys + const [signature1] = signAndAddSignatures(transaction, key1); + + // Remove all signatures for key1 and capture the returned value + const removedSignatures = transaction.removeSignature( + key1.publicKey, + ); + + // Check the format of the returned value + expect(removedSignatures).to.be.an("array"); + expect(removedSignatures.length).to.equal(1); + expect(removedSignatures[0]).to.be.instanceOf(Uint8Array); + + // Ensure the returned signatures are the ones that were removed + expect(removedSignatures).to.include.members([signature1]); + }); + + it("should return all removed signatures in the expected object format when clearing all signatures", function () { + // Sign the transaction with multiple keys + signAndAddSignatures(transaction, key1, key2, key3); + + // Clear all signatures and capture the returned value + const removedSignatures = transaction.removeAllSignatures(); + + // Check the format of the returned value + expect(removedSignatures).to.be.an("object"); + + // Ensure the object has the correct structure + expect(removedSignatures).to.have.property( + key1.publicKey.toStringRaw(), + ); + expect(removedSignatures[key1.publicKey.toStringRaw()]).to.be.an( + "array", + ); + expect(removedSignatures).to.have.property( + key2.publicKey.toStringRaw(), + ); + expect(removedSignatures[key2.publicKey.toStringRaw()]).to.be.an( + "array", + ); + expect(removedSignatures).to.have.property( + key3.publicKey.toStringRaw(), + ); + expect(removedSignatures[key3.publicKey.toStringRaw()]).to.be.an( + "array", + ); + + // Ensure the removed signatures are in the expected format + const signaturesArray1 = + removedSignatures[key1.publicKey.toStringRaw()]; + const signaturesArray2 = + removedSignatures[key2.publicKey.toStringRaw()]; + const signaturesArray3 = + removedSignatures[key3.publicKey.toStringRaw()]; + + [signaturesArray1, signaturesArray2, signaturesArray3].forEach( + (signaturesArray) => { + signaturesArray.forEach((sig) => { + expect(sig).to.be.instanceOf(Uint8Array); + }); + }, + ); + }); + + it("should return an empty object when no signatures are present", function () { + expect(transaction._signerPublicKeys.size).to.equal(0); + + // Clear all signatures and capture the returned value + const removedSignatures = transaction.removeAllSignatures(); + + // Check the format of the returned value + expect(removedSignatures).to.be.an("object"); + expect(Object.keys(removedSignatures)).to.have.lengthOf(0); + }); + }); });