@hashgraph/sdk
Version:
1,447 lines (1,247 loc) • 76.9 kB
JavaScript
// SPDX-License-Identifier: Apache-2.0
import Hbar from "../Hbar.js";
import TransactionResponse from "./TransactionResponse.js";
import TransactionId from "./TransactionId.js";
import TransactionHashMap from "./TransactionHashMap.js";
import SignatureMap from "./SignatureMap.js";
import SignatureMapLegacy from "./SignatureMapLegacy.js";
import Executable, { ExecutionState } from "../Executable.js";
import Status from "../Status.js";
import Long from "long";
import * as sha384 from "../cryptography/sha384.js";
import * as hex from "../encoding/hex.js";
import * as HieroProto from "@hashgraph/proto";
import PrecheckStatusError from "../PrecheckStatusError.js";
import AccountId from "../account/AccountId.js";
import PublicKey from "../PublicKey.js";
import List from "./List.js";
import Timestamp from "../Timestamp.js";
import * as util from "../util.js";
import CustomFeeLimit from "./CustomFeeLimit.js";
import Key from "../Key.js";
import SignableNodeTransactionBodyBytes from "./SignableNodeTransactionBodyBytes.js";
/**
* @typedef {import("bignumber.js").default} BigNumber
*/
/**
* @typedef {import("../schedule/ScheduleCreateTransaction.js").default} ScheduleCreateTransaction
* @typedef {import("../PrivateKey.js").default} PrivateKey
* @typedef {import("../channel/Channel.js").default} Channel
* @typedef {import("../client/Client.js").default<*, *>} Client
* @typedef {import("../Signer.js").Signer} Signer
*/
// 90 days (in seconds)
export const DEFAULT_AUTO_RENEW_PERIOD = Long.fromValue(7776000);
// maximum value of i64 (so there is never a record generated)
export const DEFAULT_RECORD_THRESHOLD = Hbar.fromTinybars(
Long.fromString("9223372036854775807"),
);
/**
* Node account ID used for batch transactions
* @type {AccountId}
*/
// @ts-ignore
const NODE_ACCOUNT_BATCH_ID = new AccountId(0, 0, 0);
// 120 seconds
const DEFAULT_TRANSACTION_VALID_DURATION = 120;
// The default message chunk size in bytes when splitting a given message.
// This value can be overriden using `setChunkSize` when preparing to submit a messsage via `TopicMessageSubmitTransaction`.
export const CHUNK_SIZE = 1024;
/**
* @type {Map<NonNullable<HieroProto.proto.TransactionBody["data"]>, (transactions: HieroProto.proto.ITransaction[], signedTransactions: HieroProto.proto.ISignedTransaction[], transactionIds: TransactionId[], nodeIds: AccountId[], bodies: HieroProto.proto.TransactionBody[]) => Transaction>}
*/
export const TRANSACTION_REGISTRY = new Map();
/**
* Base class for all transactions that may be submitted to Hedera.
*
* @abstract
* @augments {Executable<HieroProto.proto.ITransaction, HieroProto.proto.ITransactionResponse, TransactionResponse>}
*/
export default class Transaction extends Executable {
// A SDK transaction is composed of multiple, raw protobuf transactions.
// These should be functionally identical, with the exception of pointing to
// different nodes.
// When retrying a transaction after a network error or retry-able
// status response, we try a different transaction and thus a different node.
constructor() {
super();
/**
* List of proto transactions that have been built from this SDK
* transaction.
*
* This is a 2-D array built into one, meaning to
* get to the next row you'd index into this array `row * rowLength + column`
* where `rowLength` is `nodeAccountIds.length`
*
* @internal
* @type {List<HieroProto.proto.ITransaction | null>}
*/
this._transactions = new List();
/**
* List of proto transactions that have been built from this SDK
* transaction.
*
* This is a 2-D array built into one, meaning to
* get to the next row you'd index into this array `row * rowLength + column`
* where `rowLength` is `nodeAccountIds.length`
*
* @internal
* @type {List<HieroProto.proto.ISignedTransaction>}
*/
this._signedTransactions = new List();
/**
* Set of public keys (as string) who have signed this transaction so
* we do not allow them to sign it again.
*
* @internal
* @type {Set<string>}
*/
this._signerPublicKeys = new Set();
/**
* The transaction valid duration
*
* @private
* @type {number}
*/
this._transactionValidDuration = DEFAULT_TRANSACTION_VALID_DURATION;
/**
* The default max transaction fee for this particular transaction type.
* Most transactions use the default of 2 Hbars, but some requests such
* as `TokenCreateTransaction` need to use a different default value.
*
* @protected
* @type {Hbar}
*/
this._defaultMaxTransactionFee = new Hbar(2);
/**
* The maximum custom fee that the user is willing to pay for the message. If left empty, the user is willing to pay any custom fee.
* If used with a transaction type that does not support custom fee limits, the transaction will fail.
* @type {CustomFeeLimit[]}
*/
this._customFeeLimits = [];
/**
* The max transaction fee on the request. This field is what users are able
* to set, not the `defaultMaxTransactionFee`. The purpose of this field is
* to allow us to determine if the user set the field explicitly, or if we're
* using the default max transation fee for the request.
*
* @private
* @type {Hbar | null}
*/
this._maxTransactionFee = null;
/**
* The transaction's memo
*
* @private
* @type {string}
*/
this._transactionMemo = "";
/**
* The list of transaction IDs. This list will almost always be of length 1.
* The only time this list will be a different length is for chunked transactions.
* The only two chunked transactions supported right now are `FileAppendTransaction`
* and `TopicMessageSubmitTransaction`
*
* @protected
* @type {List<TransactionId>}
*/
this._transactionIds = new List();
/**
* A list of public keys that will be added to the requests signatures
*
* @private
* @type {PublicKey[]}
*/
this._publicKeys = [];
/**
* The list of signing function 1-1 with `_publicKeys` which sign the request.
* The reason this list allows `null` is because if we go from bytes into
* a transaction, then we know the public key, but we don't have the signing function.
*
* @private
* @type {(((message: Uint8Array) => Promise<Uint8Array>) | null)[]}
*/
this._transactionSigners = [];
/**
* Determine if we should regenerate transaction IDs when we receive `TRANSACITON_EXPIRED`
*
* @private
* @type {?boolean}
*/
this._regenerateTransactionId = null;
/**
* The key used to sign the batch transaction
*
* @private
* @type {Key | null}
*/
this._batchKey = null;
/**
* Whether the transaction is throttled
*
* @private
* @type {boolean}
*/
this._isThrottled = false;
}
/**
* Deserialize a transaction from bytes. The bytes can either be a `proto.Transaction` or
* `proto.TransactionList`.
*
* @param {Uint8Array} bytes
* @returns {Transaction}
*/
static fromBytes(bytes) {
/** @type {HieroProto.proto.ISignedTransaction[]} */
const signedTransactions = [];
/** @type {TransactionId[]} */
const transactionIds = [];
/** @type {AccountId[]} */
const nodeIds = [];
/** @type {string[]} */
const transactionIdStrings = [];
/** @type {string[]} */
const nodeIdStrings = [];
/** @type {HieroProto.proto.TransactionBody[]} */
const bodies = [];
const list =
HieroProto.proto.TransactionList.decode(bytes).transactionList;
// If the list is of length 0, then teh bytes provided were not a
// `proto.TransactionList`
//
// FIXME: We should also check to make sure the bytes length is greater than
// 0 otherwise this check is wrong?
if (list.length === 0) {
const transaction = HieroProto.proto.Transaction.decode(bytes);
// We support `Transaction.signedTransactionBytes` and
// `Transaction.bodyBytes` + `Transaction.sigMap`. If the bytes represent the
// latter, convert them into `signedTransactionBytes`
if (transaction.signedTransactionBytes.length !== 0) {
list.push(transaction);
} else {
list.push({
signedTransactionBytes:
HieroProto.proto.SignedTransaction.encode({
sigMap: transaction.sigMap,
bodyBytes: transaction.bodyBytes,
}).finish(),
});
}
}
// This loop is responsible for fill out the `signedTransactions`, `transactionIds`,
// `nodeIds`, and `bodies` variables.
for (const transaction of list) {
// The `bodyBytes` or `signedTransactionBytes` should not be null
if (
transaction.bodyBytes == null &&
transaction.signedTransactionBytes == null
) {
throw new Error(
"bodyBytes and signedTransactionBytes are null",
);
}
if (transaction.bodyBytes && transaction.bodyBytes.length != 0) {
// Decode a transaction
const body = HieroProto.proto.TransactionBody.decode(
transaction.bodyBytes,
);
// Make sure the transaction ID within the body is set
if (body.transactionID != null) {
const transactionId = TransactionId._fromProtobuf(
/** @type {HieroProto.proto.ITransactionID} */ (
body.transactionID
),
);
// If we haven't already seen this transaction ID in the list, add it
if (
!transactionIdStrings.includes(transactionId.toString())
) {
transactionIds.push(transactionId);
transactionIdStrings.push(transactionId.toString());
}
}
// Make sure the node account ID within the body is set
if (body.nodeAccountID != null) {
const nodeAccountId = AccountId._fromProtobuf(
/** @type {HieroProto.proto.IAccountID} */ (
body.nodeAccountID
),
);
// If we haven't already seen this node account ID in the list, add it
if (!nodeIdStrings.includes(nodeAccountId.toString())) {
nodeIds.push(nodeAccountId);
nodeIdStrings.push(nodeAccountId.toString());
}
}
// Make sure the body is set
if (body.data == null) {
throw new Error(
"(BUG) body.data was not set in the protobuf",
);
}
bodies.push(body);
}
if (
transaction.signedTransactionBytes &&
transaction.signedTransactionBytes.length != 0
) {
// Decode a signed transaction
const signedTransaction =
HieroProto.proto.SignedTransaction.decode(
transaction.signedTransactionBytes,
);
signedTransactions.push(signedTransaction);
// Decode a transaction body
const body = HieroProto.proto.TransactionBody.decode(
signedTransaction.bodyBytes,
);
// Make sure the transaction ID within the body is set
if (body.transactionID != null) {
const transactionId = TransactionId._fromProtobuf(
/** @type {HieroProto.proto.ITransactionID} */ (
body.transactionID
),
);
// If we haven't already seen this transaction ID in the list, add it
if (
!transactionIdStrings.includes(transactionId.toString())
) {
transactionIds.push(transactionId);
transactionIdStrings.push(transactionId.toString());
}
}
// Make sure the node account ID within the body is set
if (body.nodeAccountID != null) {
const nodeAccountId = AccountId._fromProtobuf(
/** @type {HieroProto.proto.IAccountID} */ (
body.nodeAccountID
),
);
// If we haven't already seen this node account ID in the list, add it
if (!nodeIdStrings.includes(nodeAccountId.toString())) {
nodeIds.push(nodeAccountId);
nodeIdStrings.push(nodeAccountId.toString());
}
}
// Make sure the body is set
if (body.data == null) {
throw new Error(
"(BUG) body.data was not set in the protobuf",
);
}
bodies.push(body);
}
}
// FIXME: We should have a length check before we access `0` since that would error
const body = bodies[0];
// We should have at least more than one body
if (body == null || body.data == null) {
throw new Error(
"No transaction found in bytes or failed to decode TransactionBody",
);
}
// Use the registry to call the right transaction's `fromProtobuf` method based
// on the `body.data` string
const fromProtobuf = TRANSACTION_REGISTRY.get(body.data); //NOSONAR
// If we forgot to update the registry we should error
if (fromProtobuf == null) {
throw new Error(
`(BUG) Transaction.fromBytes() not implemented for type ${body.data}`,
);
}
// That the specific transaction type from protobuf implementation and pass in all the
// information we've gathered.
return fromProtobuf(
list,
signedTransactions,
transactionIds,
nodeIds,
bodies,
);
}
/**
* Convert this transaction a `ScheduleCreateTransaction`
*
* @returns {ScheduleCreateTransaction}
*/
schedule() {
this._requireNotFrozen();
if (SCHEDULE_CREATE_TRANSACTION.length != 1) {
throw new Error(
"ScheduleCreateTransaction has not been loaded yet",
);
}
return SCHEDULE_CREATE_TRANSACTION[0]()._setScheduledTransaction(this);
}
/**
* @description Batchify method is used to mark a transaction as part of a batch transaction or make it so-called inner transaction.
* The Transaction will be frozen and signed by the operator of the client.
*
* @param {import("../client/Client.js").default<Channel, *>} client
* @param {Key} batchKey
* @returns {Promise<this>}
*/
async batchify(client, batchKey) {
this._requireNotFrozen();
this.setBatchKey(batchKey);
return await this.signWithOperator(client);
}
/**
* This method is called by each `*Transaction._fromProtobuf()` method. It does
* all the finalization before the user gets hold of a complete `Transaction`
*
* @template {Transaction} TransactionT
* @param {TransactionT} transaction
* @param {HieroProto.proto.ITransaction[]} transactions
* @param {HieroProto.proto.ISignedTransaction[]} signedTransactions
* @param {TransactionId[]} transactionIds
* @param {AccountId[]} nodeIds
* @param {HieroProto.proto.ITransactionBody[]} bodies
* @returns {TransactionT}
*/
static _fromProtobufTransactions(
transaction,
transactions,
signedTransactions,
transactionIds,
nodeIds,
bodies,
) {
const body = bodies[0];
// "row" of the 2-D `bodies` array has all the same contents except for `nodeAccountID`
for (let i = 0; i < transactionIds.length; i++) {
for (let j = 0; j < nodeIds.length - 1; j++) {
if (
!util.compare(
bodies[i * nodeIds.length + j],
bodies[i * nodeIds.length + j + 1],
// eslint-disable-next-line ie11/no-collection-args
new Set(["nodeAccountID"]),
)
) {
throw new Error("failed to validate transaction bodies");
}
}
}
// Remove node account IDs of 0
// _IIRC_ this was initial due to some funny behavior with `ScheduleCreateTransaction`
// We may be able to remove this.
const zero = new AccountId(0);
for (let i = 0; i < nodeIds.length; i++) {
if (nodeIds[i].equals(zero)) {
nodeIds.splice(i--, 1);
}
}
// Set the transactions accordingly, but don't lock the list because transactions can
// be regenerated if more signatures are added
transaction._transactions.setList(transactions);
// Set the signed transactions accordingly. Although, they
// can be manipulated if for instance more signatures are added
transaction._signedTransactions.setList(signedTransactions);
// Set the transaction IDs accordingly
transaction._transactionIds.setList(transactionIds);
// Set the node account IDs accordingly
transaction._nodeAccountIds.setList(nodeIds);
// Make sure to update the rest of the fields
transaction._transactionValidDuration =
body.transactionValidDuration != null &&
body.transactionValidDuration.seconds != null
? Long.fromValue(body.transactionValidDuration.seconds).toInt()
: DEFAULT_TRANSACTION_VALID_DURATION;
transaction._maxTransactionFee =
body.transactionFee != null &&
body.transactionFee > new Long(0, 0, true)
? Hbar.fromTinybars(body.transactionFee)
: null;
transaction._customFeeLimits =
body.maxCustomFees != null
? body.maxCustomFees?.map((fee) =>
CustomFeeLimit._fromProtobuf(fee),
)
: [];
transaction._batchKey =
body.batchKey != null ? Key._fromProtobufKey(body?.batchKey) : null;
transaction._transactionMemo = body.memo != null ? body.memo : "";
// Loop over a single row of `signedTransactions` and add all the public
// keys to the `signerPublicKeys` set, and `publicKeys` list with
// `null` in the `transactionSigners` at the same index.
for (let i = 0; i < nodeIds.length; i++) {
const tx = signedTransactions[i] || transactions[i];
if (tx.sigMap != null && tx.sigMap.sigPair != null) {
for (const sigPair of tx.sigMap.sigPair) {
transaction._signerPublicKeys.add(
hex.encode(
/** @type {Uint8Array} */ (sigPair.pubKeyPrefix),
),
);
transaction._publicKeys.push(
PublicKey.fromBytes(
/** @type {Uint8Array} */ (sigPair.pubKeyPrefix),
),
);
transaction._transactionSigners.push(null);
}
}
}
return transaction;
}
/**
* Set the node account IDs
*
* @override
* @param {AccountId[]} nodeIds
* @returns {this}
*/
setNodeAccountIds(nodeIds) {
// The reason we overwrite this method is simply because we need to call `requireNotFrozen()`
// Now that I think of it, we could just add an abstract method `setterPrerequiest()` which
// by default does nothing, and `Executable` can call. Then we'd only need to overwrite that
// method once.
this._requireNotFrozen();
super.setNodeAccountIds(nodeIds);
return this;
}
/**
* Get the transaction valid duration
*
* @returns {number}
*/
get transactionValidDuration() {
return this._transactionValidDuration;
}
/**
* Protobuf encoding has specific rules about how data is serialized
* Different fields take different amounts of space depending on their values
* The actual wire format size can only be determined after encoding
*
* @returns {Promise<number>}
*/
get size() {
this._requireFrozen();
return this._makeRequestAsync().then(
(request) =>
HieroProto.proto.Transaction.encode(request).finish().length,
);
}
/**
* Get the transaction body size
* Protobuf encoding has specific rules about how data is serialized
* Different fields take different amounts of space depending on their values
* The actual wire format size can only be determined after encoding
*
* @returns {number}
*/
get bodySize() {
const body = this._makeTransactionBody(AccountId.fromString("0.0.0"));
return HieroProto.proto.TransactionBody.encode(body).finish().length;
}
/**
* Sets the duration (in seconds) that this transaction is valid for.
*
* This is defaulted to 120 seconds (from the time its executed).
*
* @param {number} validDuration
* @returns {this}
*/
setTransactionValidDuration(validDuration) {
this._requireNotFrozen();
this._transactionValidDuration = validDuration;
return this;
}
/**
* Get the max transaction fee
*
* @returns {?Hbar}
*/
get maxTransactionFee() {
return this._maxTransactionFee;
}
/**
* Set the maximum transaction fee the operator (paying account)
* is willing to pay.
*
* @param {number | string | Long | BigNumber | Hbar} maxTransactionFee
* @returns {this}
*/
setMaxTransactionFee(maxTransactionFee) {
this._requireNotFrozen();
this._maxTransactionFee =
maxTransactionFee instanceof Hbar
? maxTransactionFee
: new Hbar(maxTransactionFee);
return this;
}
/**
* Is transaction ID regeneration enabled
*
* @returns {?boolean}
*/
get regenerateTransactionId() {
return this._regenerateTransactionId;
}
/**
* Set the maximum transaction fee the operator (paying account)
* is willing to pay.
*
* @param {boolean} regenerateTransactionId
* @returns {this}
*/
setRegenerateTransactionId(regenerateTransactionId) {
this._requireNotFrozen();
this._regenerateTransactionId = regenerateTransactionId;
return this;
}
/**
* Get the transaction memo
*
* @returns {string}
*/
get transactionMemo() {
return this._transactionMemo;
}
/**
* Set a note or description to be recorded in the transaction
* record (maximum length of 100 bytes).
*
* @param {string} transactionMemo
* @returns {this}
*/
setTransactionMemo(transactionMemo) {
this._requireNotFrozen();
this._transactionMemo = transactionMemo;
return this;
}
/**
* Get the curent transaction ID
*
* @returns {?TransactionId}
*/
get transactionId() {
if (this._transactionIds.isEmpty) {
return null;
}
// If a user calls `.transactionId` that means we need to use that transaction ID
// and **not** regenerate it. To do this, we simply lock the transaction ID list.
//
// This may be a little conffusing since a user can enable transaction ID regenration
// explicity, but if they call `.transactionId` then we will not regenerate transaction
// IDs.
this._transactionIds.setLocked();
return this._transactionIds.current;
}
/**
* Set the ID for this transaction.
*
* The transaction ID includes the operator's account ( the account paying the transaction
* fee). If two transactions have the same transaction ID, they won't both have an effect. One
* will complete normally and the other will fail with a duplicate transaction status.
*
* Normally, you should not use this method. Just before a transaction is executed, a
* transaction ID will be generated from the operator on the client.
*
* @param {TransactionId} transactionId
* @returns {this}
*/
setTransactionId(transactionId) {
this._requireNotFrozen();
this._transactionIds.setList([transactionId]).setLocked();
return this;
}
/**
* How many chunk sizes are expected
* @abstract
* @internal
* @returns {number}
*/
getRequiredChunks() {
return 1;
}
/**
* Get the body sizes for all chunks in a Chunked transaction.
* For transactions with multiple chunks (like large topic message submissions),
* this returns an array containing the size of each chunk's transaction body.
* The size is calculated by encoding the transaction body to protobuf format.
*
* @returns {number[]} An array of body sizes, where each element represents
* the size in bytes of a chunk's transaction body
*
*/
get bodySizeAllChunks() {
const bodySizes = [];
// Store sizes for each chunk
for (let i = 0; i < this.getRequiredChunks(); i++) {
// Set index directly
this._transactionIds.index = i;
// Use super.bodySize to access the base class implementation
bodySizes.push(this.bodySize);
}
// Restore to initial index
this._transactionIds.index = 0;
return bodySizes;
}
/**
* Sign the transaction with the private key
* **NOTE**: This is a thin wrapper around `.signWith()`
*
* @param {PrivateKey} privateKey
* @returns {Promise<this>}
*/
sign(privateKey) {
return this.signWith(privateKey.publicKey, (message) =>
Promise.resolve(privateKey.sign(message)),
);
}
/**
* Sign the transaction with the public key and signer function
*
* If sign on demand is enabled no signing will be done immediately, instead
* the private key signing function and public key are saved to be used when
* a user calls an exit condition method (not sure what a better name for this is)
* such as `toBytes[Async]()`, `getTransactionHash[PerNode]()` or `execute()`.
*
* @param {PublicKey} publicKey
* @param {(message: Uint8Array) => Promise<Uint8Array>} transactionSigner
* @returns {Promise<this>}
*/
async signWith(publicKey, transactionSigner) {
// If signing on demand is disabled, we need to make sure
// the request is frozen
if (!this._signOnDemand) {
this._requireFrozen();
}
const publicKeyData = publicKey.toBytesRaw();
// note: this omits the DER prefix on purpose because Hedera doesn't
// support that in the protobuf. this means that we would fail
// to re-inflate [this._signerPublicKeys] during [fromBytes] if we used DER
// prefixes here
const publicKeyHex = hex.encode(publicKeyData);
if (this._signerPublicKeys.has(publicKeyHex)) {
// this public key has already signed this transaction
return this;
}
// If we add a new signer, then we need to re-create all transactions
this._transactions.clear();
// Save the current public key so we don't attempt to sign twice
this._signerPublicKeys.add(publicKeyHex);
this._publicKeys.push(publicKey);
this._transactionSigners.push(transactionSigner);
if (this._signOnDemand) {
return this;
}
// If we get here, signing on demand is disabled, this means the transaction
// is frozen and we need to sign all the transactions immediately. If we're
// signing all the transactions immediately, we need to lock the node account IDs
// and transaction IDs.
// Now that I think of it, this code should likely exist in `freezeWith()`?
this._transactionIds.setLocked();
this._nodeAccountIds.setLocked();
// Sign each signed transatcion
for (const signedTransaction of this._signedTransactions.list) {
const bodyBytes = /** @type {Uint8Array} */ (
signedTransaction.bodyBytes
);
const signature = await transactionSigner(bodyBytes);
if (signedTransaction.sigMap == null) {
signedTransaction.sigMap = {};
}
if (signedTransaction.sigMap.sigPair == null) {
signedTransaction.sigMap.sigPair = [];
}
signedTransaction.sigMap.sigPair.push(
publicKey._toProtobufSignature(signature),
);
}
return this;
}
/**
* Sign the transaction with the client operator. This is a thin wrapper
* around `.signWith()`
*
* **NOTE**: If client does not have an operator set, this method will throw
*
* @param {import("../client/Client.js").default<Channel, *>} client
* @returns {Promise<this>}
*/
signWithOperator(client) {
const operator = client._operator;
if (operator == null) {
throw new Error(
"`client` must have an operator to sign with the operator",
);
}
if (!this._isFrozen()) {
this.freezeWith(client);
}
return this.signWith(operator.publicKey, operator.transactionSigner);
}
/**
* Resets the transaction to its initial state
* @param {Client} client
*/
_resetTransaction(client) {
if (!client.operatorAccountId) {
throw new Error("Client must have an operator account ID");
}
this.logger?.info("Resetting transaction id and resigning");
const newTxId = TransactionId.generate(client.operatorAccountId);
this._transactionIds.clear();
this._signedTransactions.clear();
this._transactionIds.setList([newTxId]);
this._isThrottled = true;
}
/**
* @deprecated - Using uint8array and uint8array[] as signaturemap is deprecated,
* use SignatureMap insted.
* @overload
* @param { PublicKey } publicKey
* @param { Uint8Array | Uint8Array[] } signatureMap
* @returns {this}
*/
/**
* @overload
* @param {PublicKey} publicKey
* @param { SignatureMap } signatureMap
* @returns {this}
*/
/**
* Add a signature explicitly
*
* @param {PublicKey} publicKey
* @param {SignatureMap | Uint8Array |Uint8Array[]} signatureMap
* @returns {this}
*/
addSignature(publicKey, signatureMap) {
if (!(signatureMap instanceof SignatureMap)) {
return this._addSignatureLegacy(publicKey, signatureMap);
}
// If the transaction isn't frozen, freeze it.
if (!this.isFrozen()) {
this.freeze();
}
const publicKeyData = publicKey.toBytesRaw();
const publicKeyHex = hex.encode(publicKeyData);
if (this._signerPublicKeys.has(publicKeyHex)) {
// this public key has already signed this transaction
return this;
}
// If we add a new signer, then we need to re-create all transactions
this._transactions.clear();
// Locking the transaction IDs and node account IDs is necessary for consistency
// between before and after execution
this._transactionIds.setLocked();
this._nodeAccountIds.setLocked();
this._signedTransactions.setLocked();
// Add the signature to the signed transaction list
for (let index = 0; index < this._signedTransactions.length; index++) {
const signedTransaction = this._signedTransactions.get(index);
if (signedTransaction.sigMap == null) {
signedTransaction.sigMap = {};
}
if (signedTransaction.sigMap.sigPair == null) {
signedTransaction.sigMap.sigPair = [];
}
if (signedTransaction.bodyBytes) {
const { transactionID, nodeAccountID } =
HieroProto.proto.TransactionBody.decode(
signedTransaction.bodyBytes,
);
if (!transactionID || !nodeAccountID) {
throw new Error(
"Transaction ID or Node Account ID not found in the signed transaction",
);
}
const transactionId =
TransactionId._fromProtobuf(transactionID);
const nodeAccountId = AccountId._fromProtobuf(nodeAccountID);
const nodeSignatures = signatureMap.get(nodeAccountId);
const transactionSignatures =
nodeSignatures?.get(transactionId);
const signature = transactionSignatures?.get(publicKey);
if (!signature) {
throw new Error(
"Signature not found for the transaction and public key",
);
}
const sigPair = publicKey._toProtobufSignature(signature);
signedTransaction.sigMap?.sigPair?.push(sigPair);
}
}
this._signerPublicKeys.add(publicKeyHex);
this._publicKeys.push(publicKey);
this._transactionSigners.push(null);
return this;
}
/**
* Add a signature explicitly
* This method supports both single and multiple signatures. A single signature will be applied to all transactions,
*
* While an array of signatures must correspond to each transaction individually.
*
* @param {PublicKey} publicKey
* @param {Uint8Array | Uint8Array[]} signature
* @returns {this}
*/
_addSignatureLegacy(publicKey, signature) {
const isSingleSignature = signature instanceof Uint8Array;
const isArraySignature = Array.isArray(signature);
if (this.getRequiredChunks() > 1) {
throw new Error(
"Add signature is not supported for chunked transactions",
);
}
// Check if it is a single signature with NOT exactly one transaction
if (isSingleSignature && this._signedTransactions.length !== 1) {
throw new Error(
"Signature array must match the number of transactions",
);
}
// Check if it's an array but the array length doesn't match the number of transactions
if (
isArraySignature &&
signature.length !== this._signedTransactions.length
) {
throw new Error(
"Signature array must match the number of transactions",
);
}
// If the transaction isn't frozen, freeze it.
if (!this.isFrozen()) {
this.freeze();
}
const publicKeyData = publicKey.toBytesRaw();
const publicKeyHex = hex.encode(publicKeyData);
if (this._signerPublicKeys.has(publicKeyHex)) {
// this public key has already signed this transaction
return this;
}
// If we add a new signer, then we need to re-create all transactions
this._transactions.clear();
// Locking the transaction IDs and node account IDs is necessary for consistency
// between before and after execution
this._transactionIds.setLocked();
this._nodeAccountIds.setLocked();
this._signedTransactions.setLocked();
const signatureArray = isSingleSignature ? [signature] : signature;
// Add the signature to the signed transaction list
for (let index = 0; index < this._signedTransactions.length; index++) {
const signedTransaction = this._signedTransactions.get(index);
if (signedTransaction.sigMap == null) {
signedTransaction.sigMap = {};
}
if (signedTransaction.sigMap.sigPair == null) {
signedTransaction.sigMap.sigPair = [];
}
signedTransaction.sigMap.sigPair.push(
publicKey._toProtobufSignature(signatureArray[index]),
);
}
this._signerPublicKeys.add(publicKeyHex);
this._publicKeys.push(publicKey);
this._transactionSigners.push(null);
return this;
}
/**
* Get the current signatures on the request
* **NOTE**: Does NOT support sign on demand
* @returns {SignatureMapLegacy}
*/
getSignaturesLegacy() {
// If a user is attempting to get signatures for a transaction, then the
// transaction must be frozen.
this._requireFrozen();
// Sign on demand must be disabled because this is the non-async version and
// signing requires awaiting callbacks.
this._requireNotSignOnDemand();
// Build all the transactions
this._buildAllTransactions();
// Lock transaction IDs, and node account IDs
this._transactionIds.setLocked();
this._nodeAccountIds.setLocked();
// Construct a signature map from this transaction
// eslint-disable-next-line deprecation/deprecation
return SignatureMapLegacy._fromTransaction(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 { Map<PublicKey, 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;
}
/**
* @deprecated - Use the legacy=flag instead to use the modern approach
* @overload
* @param {true} legacy
* @returns {SignatureMapLegacy}
*/
/**
* @overload
* @param {false} [legacy]
* @returns {SignatureMap}
*/
/**
* Get the current signatures on the request
*
* **NOTE**: Does NOT support sign on demand
* @param {boolean} [legacy]
* @returns {SignatureMap | SignatureMapLegacy}
*/
getSignatures(legacy) {
if (legacy) {
return this.getSignaturesLegacy();
}
// If a user is attempting to get signatures for a transaction, then the
// transaction must be frozen.
this._requireFrozen();
// Sign on demand must be disabled because this is the non-async version and
// signing requires awaiting callbacks.
this._requireNotSignOnDemand();
// Build all the transactions
this._buildAllTransactions();
// Lock transaction IDs, and node account IDs
this._transactionIds.setLocked();
this._nodeAccountIds.setLocked();
// Construct a signature map from this transaction
return SignatureMap._fromTransaction(this);
}
/**
* Get the current signatures on the request
*
* **NOTE**: Supports sign on demand
*
* @returns {Promise<SignatureMap>}
*/
async getSignaturesAsync() {
// If sign on demand is enabled, we don't need to care about being frozen
// since we can just regenerate and resign later if some field of the transaction
// changes.
// Locking the transaction IDs and node account IDs is necessary for consistency
// between before and after execution
this._transactionIds.setLocked();
this._nodeAccountIds.setLocked();
// Build all transactions, and sign them
await this._buildAllTransactionsAsync();
// Lock transaction IDs, and node account IDs
this._transactions.setLocked();
this._signedTransactions.setLocked();
// Construct a signature map from this transaction
return SignatureMap._fromTransaction(this);
}
/**
* Not sure why this is called `setTransactionId()` when it doesn't set anything...
* FIXME: Remove this?
*/
_setTransactionId() {
if (this._operatorAccountId == null && this._transactionIds.isEmpty) {
throw new Error(
"`transactionId` must be set or `client` must be provided with `freezeWith`",
);
}
}
/**
* Set the node account IDs using the client
*
* @param {?import("../client/Client.js").default<Channel, *>} client
*/
_setNodeAccountIds(client) {
if (!this._nodeAccountIds.isEmpty) {
return;
}
if (client == null) {
throw new Error(
"`nodeAccountId` must be set or `client` must be provided with `freezeWith`",
);
}
this._nodeAccountIds.setList(
client._network.getNodeAccountIdsForExecute(),
);
}
/**
* @description Set the key that will sign the batch of which this Transaction is a part of.
* @param {Key} batchKey
* @returns {this}
*/
setBatchKey(batchKey) {
this._requireNotFrozen();
this._batchKey = batchKey;
return this;
}
/**
* @description Get the key that will sign the batch of which this Transaction is a part of.
* @returns {Key | null | undefined}
*/
get batchKey() {
return this._batchKey;
}
/**
* Returns a List of SignableNodeTransactionBodyBytes for each node the transaction is intended for.
* These are the canonical bytes that must be signed externally (e.g., via HSM).
*
* @returns {SignableNodeTransactionBodyBytes[]}
*/
get signableNodeBodyBytesList() {
this._requireFrozen();
return this._signedTransactions.list.map((signedTransaction) => {
if (!signedTransaction.bodyBytes) {
throw new Error("Missing bodyBytes in signed transaction.");
}
const body = HieroProto.proto.TransactionBody.decode(
signedTransaction.bodyBytes,
);
if (!body.nodeAccountID) {
throw new Error("Missing nodeAccountID in transaction body.");
}
const nodeAccountId = AccountId._fromProtobuf(body.nodeAccountID);
if (!body.transactionID) {
throw new Error("Missing transactionID in transaction body.");
}
const transactionId = TransactionId._fromProtobuf(
body.transactionID,
);
return new SignableNodeTransactionBodyBytes(
nodeAccountId,
transactionId,
signedTransaction.bodyBytes,
);
});
}
/**
* Build all the signed transactions from the node account IDs
*
* @private
*/
_buildSignedTransactions() {
if (this._signedTransactions.locked) {
return;
}
this._signedTransactions.setList(
this._nodeAccountIds.list.map((nodeId) =>
this._makeSignedTransaction(nodeId),
),
);
}
/**
* Build all the signed transactions from the node account IDs
*
* @internal
*/
_buildIncompleteTransactions() {
if (this._nodeAccountIds.length == 0) {
this._transactions.setList([this._makeSignedTransaction(null)]);
} else {
// In case the node account ids are set
this._transactions.setList(
this._nodeAccountIds.list.map((nodeId) =>
this._makeSignedTransaction(nodeId),
),
);
}
}
/**
* Freeze this transaction from future modification to prepare for
* signing or serialization.
*
* @returns {this}
*/
freeze() {
return this.freezeWith(null);
}
/**
* @param {?AccountId} accountId
*/
_freezeWithAccountId(accountId) {
if (this._operatorAccountId == null) {
this._operatorAccountId = accountId;
}
}
/**
* Freeze this transaction from further modification to prepare for
* signing or serialization.
*
* Will use the `Client`, if available, to generate a default Transaction ID and select 1/3
* nodes to prepare this transaction for.
*
* @param {?import("../client/Client.js").default<Channel, *>} client
* @returns {this}
*/
freezeWith(client) {
// Set sign on demand based on client
this._signOnDemand = client != null ? client.signOnDemand : false;
// Save the operator
this._operator = client != null ? client._operator : null;
this._freezeWithAccountId(
client != null ? client.operatorAccountId : null,
);
// Set max transaction fee to either `this._maxTransactionFee`,
// `client._defaultMaxTransactionFee`, or `this._defaultMaxTransactionFee`
// in that priority order depending on if `this._maxTransactionFee` has
// been set or if `client._defaultMaxTransactionFee` has been set.
this._maxTransactionFee =
this._maxTransactionFee == null
? client != null && client.defaultMaxTransactionFee != null
? client.defaultMaxTransactionFee
: this._defaultMaxTransactionFee
: this._maxTransactionFee;
// Determine if transaction ID generation should be enabled.
this._regenerateTransactionId =
client != null && this._regenerateTransactionId == null
? client.defaultRegenerateTransactionId
: this._regenerateTransactionId;
// Set the node account IDs via client
if (this.batchKey) {
this._nodeAccountIds.setList([NODE_ACCOUNT_BATCH_ID]);
} else {