diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 515ea62af..b97f4bbbc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -112,7 +112,7 @@ jobs: with: installMirrorNode: true hieroVersion: v0.65.0 - mirrorNodeVersion: v0.138.0 + mirrorNodeVersion: v0.141.0 grpcProxyPort: 8080 - name: Set Operator Account @@ -212,7 +212,7 @@ jobs: with: installMirrorNode: true hieroVersion: v0.65.0 - mirrorNodeVersion: v0.138.0 + mirrorNodeVersion: v0.141.0 grpcProxyPort: 8080 - name: Set Operator Account diff --git a/packages/proto/src/proto/fee.proto b/packages/proto/src/proto/fee.proto new file mode 100644 index 000000000..771d94849 --- /dev/null +++ b/packages/proto/src/proto/fee.proto @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package com.hedera.mirror.api.proto; +option java_package = "com.hedera.mirror.api.proto"; + +import "services_transaction.proto"; + +/** + * Determines whether the fee estimation depends on network state (e.g., whether an account exists or requires creation + * during a transfer). + */ +enum EstimateMode { + /* + * Estimate based on intrinsic properties plus the latest known state (e.g., check if accounts + * exist, load token associations). This is the default if no mode is specified. + */ + STATE = 0; + + /* + * Estimate based solely on the transaction's inherent properties (e.g., size, signatures, keys). Ignores + * state-dependent factors. + */ + INTRINSIC = 1; +} + +/** + * Request object for users, SDKs, and tools to query expected fees without + * submitting transactions to the network. + */ +message FeeEstimateQuery { + /** + * The mode of fee estimation. Defaults to `STATE` if omitted. + */ + EstimateMode mode = 1; + + /** + * The raw HAPI transaction that should be estimated. + */ + .proto.Transaction transaction = 2; +} + +/** + * The response containing the estimated transaction fees. + */ +message FeeEstimateResponse { + /** + * The mode that was used to calculate the fees. + */ + EstimateMode mode = 1; + + /** + * The network fee component which covers the cost of gossip, consensus, + * signature verifications, fee payment, and storage. + */ + NetworkFee network = 2; + + /** + * The node fee component which is to be paid to the node that submitted the + * transaction to the network. This fee exists to compensate the node for the + * work it performed to pre-check the transaction before submitting it, and + * incentivizes the node to accept new transactions from users. + */ + FeeEstimate node = 3; + + /** + * An array of strings for any caveats (e.g., ["Fallback to worst-case due to missing state"]). + */ + repeated string notes = 4; + + /** + * The service fee component which covers execution costs, state saved in the + * Merkle tree, and additional costs to the blockchain storage. + */ + FeeEstimate service = 5; + + /** + * The sum of the network, node, and service subtotals in tinycents. + */ + uint64 total = 6; +} + +/** + * The fee estimate for the network component. Includes the base fee and any + * extras associated with it. + */ +message FeeEstimate { + /** + * The base fee price, in tinycents. + */ + uint64 base = 1; + + /** + * The extra fees that apply for this fee component. + */ + repeated FeeExtra extras = 2; +} + +/** + * The extra fee charged for the transaction. + */ +message FeeExtra { + /** + * The charged count of items as calculated by `max(0, count - included)`. + */ + uint32 charged = 1; + + /** + * The actual count of items received. + */ + uint32 count = 2; + + /** + * The fee price per unit in tinycents. + */ + uint64 fee_per_unit = 3; + + /** + * The count of this "extra" that is included for free. + */ + uint32 included = 4; + + /** + * The unique name of this extra fee as defined in the fee schedule. + */ + string name = 5; + + /** + * The subtotal in tinycents for this extra fee. Calculated by multiplying the + * charged count by the fee_per_unit. + */ + uint64 subtotal = 6; +} + +/** + * The network fee component which covers the cost of gossip, consensus, + * signature verifications, fee payment, and storage. + */ +message NetworkFee { + /** + * Multiplied by the node fee to determine the total network fee. + */ + uint32 multiplier = 1; + + /** + * The subtotal in tinycents for the network fee component which is calculated by + * multiplying the node subtotal by the network multiplier. + */ + uint64 subtotal = 2; +} diff --git a/src/exports.js b/src/exports.js index a794f5eb2..c4477d22d 100644 --- a/src/exports.js +++ b/src/exports.js @@ -93,6 +93,12 @@ export { default as LiveHashQuery } from "./account/LiveHashQuery.js"; export { default as MaxQueryPaymentExceeded } from "./MaxQueryPaymentExceeded.js"; export { default as MirrorNodeContractCallQuery } from "./query/MirrorNodeContractCallQuery.js"; export { default as MirrorNodeContractEstimateQuery } from "./query/MirrorNodeContractEstimateQuery.js"; +export { default as FeeEstimate } from "./query/FeeEstimate.js"; +export { default as FeeEstimateQuery } from "./query/FeeEstimateQuery.js"; +export { default as FeeEstimateResponse } from "./query/FeeEstimateResponse.js"; +export { default as FeeExtra } from "./query/FeeExtra.js"; +export { default as NetworkFee } from "./query/NetworkFee.js"; +export { default as FeeEstimateMode } from "./query/enums/FeeEstimateMode.js"; export { default as NodeAddressBook } from "./address_book/NodeAddressBook.js"; export { default as NetworkVersionInfo } from "./network/NetworkVersionInfo.js"; export { default as NetworkVersionInfoQuery } from "./network/NetworkVersionInfoQuery.js"; diff --git a/src/query/FeeEstimate.js b/src/query/FeeEstimate.js new file mode 100644 index 000000000..e02475a46 --- /dev/null +++ b/src/query/FeeEstimate.js @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +import Long from "long"; +import FeeExtra from "./FeeExtra.js"; + +/** + * The fee estimate for a component. Includes the base fee and any extras. + */ +export default class FeeEstimate { + /** + * @param {object} props + * @param {Long | number} props.base - The base fee price, in tinycents + * @param {FeeExtra[]} props.extras - The extra fees that apply for this fee component + */ + constructor(props) { + /** + * The base fee price, in tinycents. + * @readonly + */ + this.base = Long.fromValue(props.base); + + /** + * The extra fees that apply for this fee component. + * @readonly + */ + this.extras = props.extras || []; + } + + /** + * @internal + * @param {import("@hashgraph/proto").com.hedera.mirror.api.proto.IFeeEstimate} feeEstimate + * @returns {FeeEstimate} + */ + static _fromProtobuf(feeEstimate) { + return new FeeEstimate({ + base: feeEstimate.base || 0, + extras: (feeEstimate.extras || []).map((extra) => + FeeExtra._fromProtobuf(extra), + ), + }); + } + + /** + * @internal + * @returns {object} + */ + _toProtobuf() { + return { + base: this.base, + extras: this.extras.map((extra) => extra._toProtobuf()), + }; + } +} diff --git a/src/query/FeeEstimateQuery.js b/src/query/FeeEstimateQuery.js new file mode 100644 index 000000000..aaad94b02 --- /dev/null +++ b/src/query/FeeEstimateQuery.js @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: Apache-2.0 +import Query from "./Query.js"; +import FeeEstimateMode from "./enums/FeeEstimateMode.js"; +import FeeEstimateResponse from "./FeeEstimateResponse.js"; +import NetworkFee from "./NetworkFee.js"; +import FeeEstimate from "./FeeEstimate.js"; +import * as HieroProto from "@hashgraph/proto"; + +/** + * @typedef {import("../channel/Channel.js").default} Channel + * @typedef {import("../channel/MirrorChannel.js").default} MirrorChannel + * @typedef {import("../channel/MirrorChannel.js").MirrorError} MirrorError + * @typedef {import("../client/Client.js").default} Client + * @typedef {import("../transaction/Transaction.js").default} Transaction + */ + +/** + * Request object for users, SDKs, and tools to query expected fees without + * submitting transactions to the network. + * @augments {Query} + */ +export default class FeeEstimateQuery extends Query { + /** + * @param {object} props + * @param {typeof FeeEstimateMode.STATE|typeof FeeEstimateMode.INTRINSIC} [props.mode] + * @param {Transaction} [props.transaction] + */ + constructor(props = {}) { + super(); + + /** + * @private + * @type {number} + */ + this._mode = FeeEstimateMode.STATE; + + /** + * @private + * @type {?Transaction} + */ + this._transaction = null; + + if (props.mode != null) { + this.setMode(props.mode); + } + + if (props.transaction != null) { + this.setTransaction(props.transaction); + } + } + + /** + * @returns {number} + */ + get mode() { + return this._mode; + } + + /** + * Set the estimation mode (optional, defaults to STATE). + * + * @param {typeof FeeEstimateMode.STATE|typeof FeeEstimateMode.INTRINSIC} mode + * @returns {FeeEstimateQuery} + */ + setMode(mode) { + const modeValue = Number(mode); + const validValues = Object.values(FeeEstimateMode).map(Number); + + if (!validValues.includes(modeValue)) { + // Generate error message with all valid modes + const validModes = Object.entries(FeeEstimateMode) + .map(([key, value]) => `${key} (${Number(value)})`) + .join(", "); + throw new Error( + `Invalid FeeEstimateMode: ${modeValue}. Must be one of: ${validModes}`, + ); + } + this._mode = modeValue; + return this; + } + + /** + * Get the current estimation mode. + * + * @returns {number} + */ + getMode() { + return this._mode; + } + + /** + * @returns {?Transaction} + */ + get transaction() { + return this._transaction; + } + + /** + * Set the transaction to estimate (required). + * + * @param {Transaction} transaction + * @returns {FeeEstimateQuery} + */ + setTransaction(transaction) { + this._transaction = transaction; + return this; + } + + /** + * Get the current transaction. + * + * @returns {?Transaction} + */ + getTransaction() { + return this._transaction; + } + + /** + * @param {Client} client + */ + _validateChecksums(client) { + if (this._transaction != null) { + this._transaction._validateChecksums(client); + } + } + + /** + * @param {Client} client + * @returns {Promise} + */ + execute(client) { + return new Promise((resolve, reject) => { + this._makeMirrorNodeRequest(client, resolve, reject); + }); + } + + /** + * @private + * @param {Client} client + * @param {(value: FeeEstimateResponse) => void} resolve + * @param {(error: Error) => void} reject + */ + _makeMirrorNodeRequest(client, resolve, reject) { + if (this._transaction == null) { + reject(new Error("FeeEstimateQuery requires a transaction")); + return; + } + + const txObj = this._transaction; + + // Ensure the transaction is prepared so chunk and node matrices exist + txObj.freezeWith(client); + // Ensure protobuf transactions exist for lookup + txObj._buildAllTransactions(); + + const rowLength = txObj._nodeAccountIds.length || 1; + const chunks = txObj.getRequiredChunks(); + + /** @type {Promise[]} */ + const perChunkPromises = []; + + /** + * @param {number} index + * @returns {Promise} + */ + const requestForIndex = (index) => + this._requestFeeEstimateForIndex(client, txObj, index); + + // Use the first node for each chunk row + for (let chunk = 0; chunk < chunks; chunk++) { + const index = chunk * rowLength + 0; + perChunkPromises.push(requestForIndex(index)); + } + + Promise.all(perChunkPromises) + .then((responses) => { + resolve(this._aggregateFeeResponses(responses)); + }) + .catch(reject); + } + + /** + * Aggregate per-chunk fee responses into a single response. + * @private + * @param {FeeEstimateResponse[]} responses + * @returns {FeeEstimateResponse} + */ + _aggregateFeeResponses(responses) { + if (responses.length === 0) { + return new FeeEstimateResponse({ + mode: this._mode, + networkFee: new NetworkFee({ + multiplier: 0, + subtotal: 0, + }), + nodeFee: new FeeEstimate({ base: 0, extras: [] }), + serviceFee: new FeeEstimate({ base: 0, extras: [] }), + notes: [], + total: 0, + }); + } + + // Aggregate results across chunks + let networkMultiplier = responses[0].networkFee.multiplier; + let networkSubtotal = 0; + let nodeBase = 0; + let serviceBase = 0; + /** @type {import("./FeeExtra.js").default[]} */ + const nodeExtras = []; + /** @type {import("./FeeExtra.js").default[]} */ + const serviceExtras = []; + const notes = []; + let total = 0; + + for (const r of responses) { + networkMultiplier = r.networkFee.multiplier; + networkSubtotal += Number(r.networkFee.subtotal); + nodeBase += Number(r.nodeFee.base); + serviceBase += Number(r.serviceFee.base); + nodeExtras.push(...r.nodeFee.extras); + serviceExtras.push(...r.serviceFee.extras); + notes.push(...r.notes); + total += Number(r.total); + } + + return new FeeEstimateResponse({ + mode: this._mode, + networkFee: new NetworkFee({ + multiplier: networkMultiplier, + subtotal: networkSubtotal, + }), + nodeFee: new FeeEstimate({ + base: nodeBase, + extras: nodeExtras, + }), + serviceFee: new FeeEstimate({ + base: serviceBase, + extras: serviceExtras, + }), + notes, + total, + }); + } + + /** + * Send a fee estimate request for the transaction chunk at a flattened index. + * @private + * @param {Client} client + * @param {Transaction} txObj + * @param {number} index + * @returns {Promise} + */ + _requestFeeEstimateForIndex(client, txObj, index) { + return new Promise((res, rej) => { + // Ensure this index is built + txObj._buildTransaction(index); + const tx = + /** @type {HieroProto.proto.ITransaction | undefined} */ ( + txObj._transactions.get(index) + ); + if (tx == null) { + rej(new Error("Failed to build transaction for fee estimate")); + return; + } + + const request = + HieroProto.com.hedera.mirror.api.proto.FeeEstimateQuery.encode({ + mode: this._mode, + transaction: tx, + }).finish(); + + client._mirrorNetwork + .getNextMirrorNode() + .getChannel() + .makeServerStreamRequest( + "NetworkService", + "getFeeEstimate", + request, + /** @param {Uint8Array} data */ (data) => { + const response = + HieroProto.com.hedera.mirror.api.proto.FeeEstimateResponse.decode( + data, + ); + res(FeeEstimateResponse._fromProtobuf(response)); + }, + /** + * @param {MirrorError | Error} error + */ (error) => { + const errorMessage = + error instanceof Error + ? error.message + : error.details || String(error); + rej( + new Error( + `Failed to estimate fees: ${errorMessage}`, + ), + ); + }, + () => {}, + ); + }); + } +} diff --git a/src/query/FeeEstimateResponse.js b/src/query/FeeEstimateResponse.js new file mode 100644 index 000000000..ccf73e3c1 --- /dev/null +++ b/src/query/FeeEstimateResponse.js @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 + +import Long from "long"; +import FeeEstimateMode from "./enums/FeeEstimateMode.js"; +import NetworkFee from "./NetworkFee.js"; +import FeeEstimate from "./FeeEstimate.js"; + +/** + * The response containing the estimated transaction fees. + */ +export default class FeeEstimateResponse { + /** + * @param {object} props + * @param {number} props.mode - The mode that was used to calculate the fees + * @param {NetworkFee} props.networkFee - The network fee component + * @param {FeeEstimate} props.nodeFee - The node fee component + * @param {FeeEstimate} props.serviceFee - The service fee component + * @param {string[]} props.notes - An array of strings for any caveats + * @param {Long | number} props.total - The sum of the network, node, and service subtotals in tinycents + */ + constructor(props) { + /** + * The mode that was used to calculate the fees. + * @readonly + */ + this.mode = props.mode; + + /** + * The network fee component which covers the cost of gossip, consensus, + * signature verifications, fee payment, and storage. + * @readonly + */ + this.networkFee = props.networkFee; + + /** + * The node fee component which is to be paid to the node that submitted the + * transaction to the network. + * @readonly + */ + this.nodeFee = props.nodeFee; + + /** + * The service fee component which covers execution costs, state saved in the + * Merkle tree, and additional costs to the blockchain storage. + * @readonly + */ + this.serviceFee = props.serviceFee; + + /** + * An array of strings for any caveats (e.g., ["Fallback to worst-case due to missing state"]). + * @readonly + */ + this.notes = props.notes || []; + + /** + * The sum of the network, node, and service subtotals in tinycents. + * @readonly + */ + this.total = Long.fromValue(props.total); + } + + /** + * @internal + * @param {import("@hashgraph/proto").com.hedera.mirror.api.proto.IFeeEstimateResponse} response + * @returns {FeeEstimateResponse} + */ + static _fromProtobuf(response) { + return new FeeEstimateResponse({ + mode: + response.mode != null + ? Number(response.mode) + : FeeEstimateMode.STATE, + networkFee: response.network + ? NetworkFee._fromProtobuf(response.network) + : new NetworkFee({ multiplier: 0, subtotal: 0 }), + nodeFee: response.node + ? FeeEstimate._fromProtobuf(response.node) + : new FeeEstimate({ base: 0, extras: [] }), + serviceFee: response.service + ? FeeEstimate._fromProtobuf(response.service) + : new FeeEstimate({ base: 0, extras: [] }), + notes: response.notes || [], + total: response.total || 0, + }); + } + + /** + * @internal + * @returns {import("@hashgraph/proto").com.hedera.mirror.api.proto.IFeeEstimateResponse} + */ + _toProtobuf() { + return { + mode: this.mode, + network: this.networkFee._toProtobuf(), + node: this.nodeFee._toProtobuf(), + service: this.serviceFee._toProtobuf(), + notes: this.notes, + total: this.total, + }; + } +} diff --git a/src/query/FeeExtra.js b/src/query/FeeExtra.js new file mode 100644 index 000000000..1a90a2324 --- /dev/null +++ b/src/query/FeeExtra.js @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 + +import Long from "long"; + +/** + * The extra fee charged for the transaction. + */ +export default class FeeExtra { + /** + * @private + * @param {object} props + * @param {string} props.name - The unique name of this extra fee as defined in the fee schedule + * @param {number} props.included - The count of this "extra" that is included for free + * @param {number} props.count - The actual count of items received + * @param {number} props.charged - The charged count of items as calculated by max(0, count - included) + * @param {Long | number} props.feePerUnit - The fee price per unit in tinycents + * @param {Long | number} props.subtotal - The subtotal in tinycents for this extra fee + */ + constructor(props) { + /** + * The unique name of this extra fee as defined in the fee schedule. + * @readonly + */ + this.name = props.name; + + /** + * The count of this "extra" that is included for free. + * @readonly + */ + this.included = props.included; + + /** + * The actual count of items received. + * @readonly + */ + this.count = props.count; + + /** + * The charged count of items as calculated by max(0, count - included). + * @readonly + */ + this.charged = props.charged; + + /** + * The fee price per unit in tinycents. + * @readonly + */ + this.feePerUnit = Long.fromValue(props.feePerUnit); + + /** + * The subtotal in tinycents for this extra fee. Calculated by multiplying the + * charged count by the feePerUnit. + * @readonly + */ + this.subtotal = Long.fromValue(props.subtotal); + } + + /** + * @internal + * @param {import("@hashgraph/proto").com.hedera.mirror.api.proto.IFeeExtra} feeExtra + * @returns {FeeExtra} + */ + static _fromProtobuf(feeExtra) { + return new FeeExtra({ + name: feeExtra.name || "", + included: feeExtra.included || 0, + count: feeExtra.count || 0, + charged: feeExtra.charged || 0, + feePerUnit: feeExtra.feePerUnit || 0, + subtotal: feeExtra.subtotal || 0, + }); + } + + /** + * @internal + * @returns {object} + */ + _toProtobuf() { + return { + name: this.name, + included: this.included, + count: this.count, + charged: this.charged, + feePerUnit: this.feePerUnit, + subtotal: this.subtotal, + }; + } +} diff --git a/src/query/NetworkFee.js b/src/query/NetworkFee.js new file mode 100644 index 000000000..978a2ba4d --- /dev/null +++ b/src/query/NetworkFee.js @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 + +import Long from "long"; + +/** + * The network fee component which covers the cost of gossip, consensus, + * signature verifications, fee payment, and storage. + */ +export default class NetworkFee { + /** + * @param {object} props + * @param {number} props.multiplier - Multiplied by the node fee to determine the total network fee + * @param {Long | number} props.subtotal - The subtotal in tinycents for the network fee component + */ + constructor(props) { + /** + * Multiplied by the node fee to determine the total network fee. + * @readonly + */ + this.multiplier = props.multiplier; + + /** + * The subtotal in tinycents for the network fee component which is calculated by + * multiplying the node subtotal by the network multiplier. + * @readonly + */ + this.subtotal = Long.fromValue(props.subtotal); + } + + /** + * @internal + * @param {import("@hashgraph/proto").com.hedera.mirror.api.proto.INetworkFee} networkFee + * @returns {NetworkFee} + */ + static _fromProtobuf(networkFee) { + return new NetworkFee({ + multiplier: networkFee.multiplier || 0, + subtotal: networkFee.subtotal || 0, + }); + } + + /** + * @internal + * @returns {object} + */ + _toProtobuf() { + return { + multiplier: this.multiplier, + subtotal: this.subtotal, + }; + } +} diff --git a/src/query/enums/FeeEstimateMode.js b/src/query/enums/FeeEstimateMode.js new file mode 100644 index 000000000..8f387b3e8 --- /dev/null +++ b/src/query/enums/FeeEstimateMode.js @@ -0,0 +1,6 @@ +const FeeEstimateMode = Object.freeze({ + STATE: 0, // Default: uses latest known state + INTRINSIC: 1, // Ignores state-dependent factors +}); + +export default FeeEstimateMode; diff --git a/test/integration/FeeEstimateQueryIntegrationTest.js b/test/integration/FeeEstimateQueryIntegrationTest.js new file mode 100644 index 000000000..5eb0f9c8d --- /dev/null +++ b/test/integration/FeeEstimateQueryIntegrationTest.js @@ -0,0 +1,544 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + FeeEstimateQuery, + FeeEstimateMode, + TransferTransaction, + TokenCreateTransaction, + TokenMintTransaction, + TopicCreateTransaction, + ContractCreateTransaction, + FileCreateTransaction, + FileAppendTransaction, + TopicMessageSubmitTransaction, + Hbar, + FileDeleteTransaction, + TopicDeleteTransaction, +} from "../../src/exports.js"; +import IntegrationTestEnv from "./client/NodeIntegrationTestEnv.js"; +import { createFungibleToken } from "./utils/Fixtures.js"; + +describe("FeeEstimateQuery Integration", function () { + let env; + + beforeAll(async function () { + env = await IntegrationTestEnv.new(); + }); + + describe("Basic Functionality Tests", function () { + it("should estimate fees for TransferTransaction with STATE mode", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const tx = new TransferTransaction() + .addHbarTransfer(env.operatorId, new Hbar(-1)) + .addHbarTransfer(env.operatorId, new Hbar(1)); + + const estimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(env.client); + + expect(estimate).to.not.be.null; + expect(estimate.mode).to.equal(FeeEstimateMode.STATE); + expect(estimate.networkFee).to.not.be.null; + expect(estimate.nodeFee).to.not.be.null; + expect(estimate.serviceFee).to.not.be.null; + expect(estimate.total.toNumber()).to.exist; + + // Validate total equals sum of components + const calculatedTotal = + estimate.networkFee.subtotal.toNumber() + + estimate.nodeFee.base.toNumber() + + estimate.nodeFee.extras.reduce( + (sum, extra) => sum + extra.subtotal.toNumber(), + 0, + ) + + estimate.serviceFee.base.toNumber() + + estimate.serviceFee.extras.reduce( + (sum, extra) => sum + extra.subtotal.toNumber(), + 0, + ); + + expect(estimate.total.toNumber()).to.be.closeTo(calculatedTotal, 1); + }); + + it("should estimate fees for TransferTransaction with INTRINSIC mode", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const tx = new TransferTransaction() + .addHbarTransfer(env.operatorId, new Hbar(-100)) + .addHbarTransfer(env.operatorId, new Hbar(100)); + + const intrinsicEstimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.INTRINSIC) + .setTransaction(tx) + .execute(env.client); + + expect(intrinsicEstimate).to.not.be.null; + expect(intrinsicEstimate.mode).to.equal(FeeEstimateMode.INTRINSIC); + expect(intrinsicEstimate.total.toNumber()).to.exist; + }); + + it("should default to STATE mode when mode is not set", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const tx = new TransferTransaction() + .addHbarTransfer(env.operatorId, new Hbar(-1)) + .addHbarTransfer(env.operatorId, new Hbar(1)); + + const estimate = await new FeeEstimateQuery() + .setTransaction(tx) + .execute(env.client); + + expect(estimate.mode).to.equal(FeeEstimateMode.STATE); + }); + + it("should throw error when transaction is not set", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + let error = null; + try { + await new FeeEstimateQuery().execute(env.client); + } catch (e) { + error = e; + } + + expect(error).to.not.be.null; + expect(error.message).to.include( + "FeeEstimateQuery requires a transaction", + ); + }); + }); + + describe("Transaction Type Coverage Tests", function () { + it("should estimate fees for TokenCreateTransaction", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const tx = new TokenCreateTransaction() + .setTokenName("Test Token") + .setTokenSymbol("TEST") + .setTreasuryAccountId(env.operatorId) + .setAdminKey(env.operatorKey); + + const estimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(env.client); + + expect(estimate).to.not.be.null; + expect(estimate.total.toNumber()).to.exist; + expect(estimate.serviceFee.base.toNumber()).to.exist; + }); + + it("should estimate fees for TokenMintTransaction", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const token = await createFungibleToken(env.client); + + const tx = new TokenMintTransaction() + .setTokenId(token) + .setAmount(100); + + const estimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(env.client); + + expect(estimate).to.not.be.null; + expect(estimate.total.toNumber()).to.exist; + + // TokenMint may have extras for additional tokens + const nodeSubtotal = + estimate.nodeFee.base.toNumber() + + estimate.nodeFee.extras.reduce( + (sum, extra) => sum + extra.subtotal.toNumber(), + 0, + ); + expect(nodeSubtotal).to.exist; + }); + + it("should estimate fees for TopicCreateTransaction", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const tx = new TopicCreateTransaction().setAdminKey( + env.operatorKey, + ); + + const estimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(env.client); + + expect(estimate).to.not.be.null; + expect(estimate.total.toNumber()).to.exist; + }); + + it("should estimate fees for ContractCreateTransaction", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const bytecode = new Uint8Array([1, 2, 3, 4, 5]); + const tx = new ContractCreateTransaction() + .setBytecode(bytecode) + .setGas(100000); + + const estimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(env.client); + + expect(estimate).to.not.be.null; + expect(estimate.total.toNumber()).to.exist; + }); + + it("should estimate fees for FileCreateTransaction", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const contents = new Uint8Array(10).fill(65); // 1000 bytes of 'A' + const tx = new FileCreateTransaction() + .setContents(contents) + .setKeys([env.operatorKey]); + + const estimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(env.client); + + expect(estimate).to.not.be.null; + expect(estimate.total.toNumber()).to.exist; + }); + }); + + describe("Fee Component Validation Tests", function () { + it("should have network.subtotal equal to node.subtotal * network.multiplier", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const tx = new TransferTransaction() + .addHbarTransfer(env.operatorId, new Hbar(-10)) + .addHbarTransfer(env.operatorId, new Hbar(10)); + + const estimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(env.client); + + const nodeSubtotal = + estimate.nodeFee.base.toNumber() + + estimate.nodeFee.extras.reduce( + (sum, extra) => sum + extra.subtotal.toNumber(), + 0, + ); + + const expectedNetworkSubtotal = + nodeSubtotal * estimate.networkFee.multiplier; + + expect(estimate.networkFee.subtotal.toNumber()).to.be.closeTo( + expectedNetworkSubtotal, + 1, + ); + }); + + it("should have total equal to network.subtotal + node.subtotal + service.subtotal", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const tx = new TransferTransaction() + .addHbarTransfer(env.operatorId, new Hbar(-5)) + .addHbarTransfer(env.operatorId, new Hbar(5)); + + const estimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(env.client); + + const nodeSubtotal = + estimate.nodeFee.base.toNumber() + + estimate.nodeFee.extras.reduce( + (sum, extra) => sum + extra.subtotal.toNumber(), + 0, + ); + + const serviceSubtotal = + estimate.serviceFee.base.toNumber() + + estimate.serviceFee.extras.reduce( + (sum, extra) => sum + extra.subtotal.toNumber(), + 0, + ); + + const expectedTotal = + estimate.networkFee.subtotal.toNumber() + + nodeSubtotal + + serviceSubtotal; + + expect(estimate.total.toNumber()).to.be.closeTo(expectedTotal, 1); + }); + }); + + describe("Chunk Transaction Tests", function () { + it("should aggregate fees for FileAppendTransaction with multiple chunks", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const operatorKey = env.operatorKey.publicKey; + + // Create a file + const response = await new FileCreateTransaction() + .setKeys([operatorKey]) + .setContents("[e2e::FileCreateTransaction]") + .execute(env.client); + + const receipt = await response.getReceipt(env.client); + const fileId = receipt.fileId; + + try { + // Create a FileAppendTransaction with large content that will be chunked + const largeContents = new Uint8Array(10).fill(1); // 10KB of 'A' + const tx = new FileAppendTransaction() + .setFileId(fileId) + .setContents(largeContents) + .setChunkSize(1); + + // Get fee estimate + const estimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(env.client); + + expect(estimate).to.not.be.null; + expect(estimate.total.toNumber()).to.exist; + + // Verify the transaction is chunked (multiple chunks) + const chunks = tx.getRequiredChunks(); + expect(chunks).to.exist; + + // Execute the actual transaction + const actualResponse = await tx.execute(env.client); + const actualReceipt = await actualResponse.getReceipt( + env.client, + ); + + // The actual fee should be close to the estimate + // Note: This is a rough check; actual fees may vary slightly + expect(actualReceipt).to.not.be.null; + } finally { + // Clean up + await ( + await new FileDeleteTransaction() + .setFileId(fileId) + .execute(env.client) + ).getReceipt(env.client); + } + }); + + it("should aggregate fees for TopicMessageSubmitTransaction with single chunk", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const operatorKey = env.operatorKey.publicKey; + + // Create a topic + const response = await new TopicCreateTransaction() + .setAdminKey(operatorKey) + .execute(env.client); + + const receipt = await response.getReceipt(env.client); + const topicId = receipt.topicId; + + // Create a small message that fits in one chunk + const smallMessage = new Uint8Array(100).fill(1); // 100 bytes of 'H' + const tx = new TopicMessageSubmitTransaction() + .setTopicId(topicId) + .setMessage(smallMessage) + .setChunkSize(100) + .setMaxChunks(1); + + const estimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(env.client); + + expect(estimate).to.not.be.null; + expect(estimate.total.toNumber()).to.exist; + }); + + it("should aggregate fees for TopicMessageSubmitTransaction with multiple chunks", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const operatorKey = env.operatorKey.publicKey; + + // Create a topic + const response = await new TopicCreateTransaction() + .setAdminKey(operatorKey) + .execute(env.client); + + const receipt = await response.getReceipt(env.client); + const topicId = receipt.topicId; + + try { + // Create a large message that will be chunked + const largeMessage = new Uint8Array(100).fill(1); // 15KB of 'M' + const tx = new TopicMessageSubmitTransaction() + .setTopicId(topicId) + .setMessage(largeMessage) + .setChunkSize(1) + .setMaxChunks(100); + + const estimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(env.client); + + expect(estimate).to.not.be.null; + expect(estimate.total.toNumber()).to.exist; + + // Verify the transaction is chunked (multiple chunks) + const chunks = tx.getRequiredChunks(); + expect(chunks).to.exist; + + // Verify aggregation: node subtotal should be sum across chunks + const nodeSubtotal = + estimate.nodeFee.base.toNumber() + + estimate.nodeFee.extras.reduce( + (sum, extra) => sum + extra.subtotal.toNumber(), + 0, + ); + expect(nodeSubtotal).to.exist; + } finally { + // Clean up + await ( + await new TopicDeleteTransaction() + .setTopicId(topicId) + .execute(env.client) + ).getReceipt(env.client); + } + }); + }); + + describe("Integration Tests", function () { + it.skip("should estimate fees close to actual transaction fees", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const tx = new TransferTransaction() + .addHbarTransfer(env.operatorId, new Hbar(-1)) + .addHbarTransfer(env.operatorId, new Hbar(1)); + + // Get estimate + const estimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(env.client); + + // Execute actual transaction + const response = await tx.execute(env.client); + await response.getReceipt(env.client); + //const record = await response.getRecord(env.client); + + // Compare estimate with actual fee + // const actualFee = record.transactionFee.toTinybars(); + + // todo: add tolerance + // Estimate should be within reasonable range (e.g., within 50% tolerance) + // This is a generous tolerance to account for network state changes + // const tolerance = actualFee * 0.5; + expect(estimate.total.toNumber()).to.exist; + }); + + it("should compare STATE and INTRINSIC mode estimates", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + const tx = new TransferTransaction() + .addHbarTransfer(env.operatorId, new Hbar(-100)) + .addHbarTransfer(env.operatorId, new Hbar(100)); + + const stateEstimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(env.client); + + const intrinsicEstimate = await new FeeEstimateQuery() + .setMode(FeeEstimateMode.INTRINSIC) + .setTransaction(tx) + .execute(env.client); + + expect(stateEstimate.total.toNumber()).to.exist; + expect(intrinsicEstimate.total.toNumber()).to.exist; + + // STATE mode typically includes state-dependent costs, so it may be + // equal or greater than INTRINSIC mode + // Note: This is not always true, but often the case + expect(stateEstimate.total.toNumber()).to.be.at.least( + intrinsicEstimate.total.toNumber() * 0.9, + ); + }); + }); + + describe("Error Handling Tests", function () { + it("should handle malformed transaction gracefully", async function () { + // Skip if no mirror network + if (env.client.mirrorNetwork.length === 0) { + return; + } + + // Create a transaction that's missing required fields + const tx = new TransferTransaction(); + + let error = null; + try { + await new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(env.client); + } catch (e) { + error = e; + } + + // Should either succeed (if SDK handles gracefully) or fail with clear error + // The exact behavior depends on how the mirror node handles invalid transactions + expect(error === null || error.message !== undefined).to.be.true; + }); + }); +}); diff --git a/test/unit/FeeEstimate.js b/test/unit/FeeEstimate.js new file mode 100644 index 000000000..cd38ee1ff --- /dev/null +++ b/test/unit/FeeEstimate.js @@ -0,0 +1,422 @@ +import FeeEstimate from "../../src/query/FeeEstimate.js"; +import FeeExtra from "../../src/query/FeeExtra.js"; +import Long from "long"; + +describe("FeeEstimate", function () { + let defaultProps; + let mockFeeExtra1; + let mockFeeExtra2; + + beforeEach(function () { + mockFeeExtra1 = new FeeExtra({ + name: "extra1", + included: 5, + count: 10, + charged: 5, + feePerUnit: 100, + subtotal: 500, + }); + + mockFeeExtra2 = new FeeExtra({ + name: "extra2", + included: 2, + count: 8, + charged: 6, + feePerUnit: 50, + subtotal: 300, + }); + + defaultProps = { + base: Long.fromNumber(1000), + extras: [mockFeeExtra1, mockFeeExtra2], + }; + }); + + describe("constructor", function () { + it("should create with all required props", function () { + const feeEstimate = new FeeEstimate(defaultProps); + + expect(feeEstimate.base.toNumber()).to.equal(1000); + expect(feeEstimate.extras).to.have.lengthOf(2); + expect(feeEstimate.extras[0]).to.equal(mockFeeExtra1); + expect(feeEstimate.extras[1]).to.equal(mockFeeExtra2); + }); + + it("should handle number values for base", function () { + const props = { + base: 500, + extras: [], + }; + + const feeEstimate = new FeeEstimate(props); + + expect(feeEstimate.base.toNumber()).to.equal(500); + expect(feeEstimate.extras).to.have.lengthOf(0); + }); + + it("should handle string values for base", function () { + const props = { + base: "1500", + extras: [], + }; + + const feeEstimate = new FeeEstimate(props); + + expect(feeEstimate.base.toNumber()).to.equal(1500); + expect(feeEstimate.extras).to.have.lengthOf(0); + }); + + it("should handle empty extras array", function () { + const props = { + base: 2000, + extras: [], + }; + + const feeEstimate = new FeeEstimate(props); + + expect(feeEstimate.base.toNumber()).to.equal(2000); + expect(feeEstimate.extras).to.have.lengthOf(0); + }); + + it("should handle undefined extras", function () { + const props = { + base: 2000, + }; + + const feeEstimate = new FeeEstimate(props); + + expect(feeEstimate.base.toNumber()).to.equal(2000); + expect(feeEstimate.extras).to.have.lengthOf(0); + }); + + it("should handle zero base", function () { + const props = { + base: 0, + extras: [], + }; + + const feeEstimate = new FeeEstimate(props); + + expect(feeEstimate.base.toNumber()).to.equal(0); + expect(feeEstimate.extras).to.have.lengthOf(0); + }); + + it("should handle single extra", function () { + const props = { + base: 1000, + extras: [mockFeeExtra1], + }; + + const feeEstimate = new FeeEstimate(props); + + expect(feeEstimate.base.toNumber()).to.equal(1000); + expect(feeEstimate.extras).to.have.lengthOf(1); + expect(feeEstimate.extras[0]).to.equal(mockFeeExtra1); + }); + }); + + describe("_fromProtobuf", function () { + it("should create from protobuf with all fields", function () { + const protoObj = { + base: Long.fromNumber(2000), + extras: [ + { + name: "protoExtra1", + included: 3, + count: 6, + charged: 3, + feePerUnit: 200, + subtotal: 600, + }, + { + name: "protoExtra2", + included: 1, + count: 5, + charged: 4, + feePerUnit: 75, + subtotal: 300, + }, + ], + }; + + const feeEstimate = FeeEstimate._fromProtobuf(protoObj); + + expect(feeEstimate.base.toNumber()).to.equal(2000); + expect(feeEstimate.extras).to.have.lengthOf(2); + expect(feeEstimate.extras[0].name).to.equal("protoExtra1"); + expect(feeEstimate.extras[1].name).to.equal("protoExtra2"); + }); + + it("should handle missing fields in protobuf", function () { + const protoObj = { + base: Long.fromNumber(1000), + }; + + const feeEstimate = FeeEstimate._fromProtobuf(protoObj); + + expect(feeEstimate.base.toNumber()).to.equal(1000); + expect(feeEstimate.extras).to.have.lengthOf(0); + }); + + it("should handle null/undefined fields in protobuf", function () { + const protoObj = { + base: null, + extras: undefined, + }; + + const feeEstimate = FeeEstimate._fromProtobuf(protoObj); + + expect(feeEstimate.base.toNumber()).to.equal(0); + expect(feeEstimate.extras).to.have.lengthOf(0); + }); + + it("should handle empty protobuf object", function () { + const protoObj = {}; + + const feeEstimate = FeeEstimate._fromProtobuf(protoObj); + + expect(feeEstimate.base.toNumber()).to.equal(0); + expect(feeEstimate.extras).to.have.lengthOf(0); + }); + + it("should handle empty extras array in protobuf", function () { + const protoObj = { + base: Long.fromNumber(500), + extras: [], + }; + + const feeEstimate = FeeEstimate._fromProtobuf(protoObj); + + expect(feeEstimate.base.toNumber()).to.equal(500); + expect(feeEstimate.extras).to.have.lengthOf(0); + }); + + it("should handle null extras in protobuf", function () { + const protoObj = { + base: Long.fromNumber(500), + extras: null, + }; + + const feeEstimate = FeeEstimate._fromProtobuf(protoObj); + + expect(feeEstimate.base.toNumber()).to.equal(500); + expect(feeEstimate.extras).to.have.lengthOf(0); + }); + }); + + describe("_toProtobuf", function () { + it("should convert to protobuf with all fields", function () { + const feeEstimate = new FeeEstimate(defaultProps); + const protoObj = feeEstimate._toProtobuf(); + + expect(protoObj.base.toNumber()).to.equal(1000); + expect(protoObj.extras).to.have.lengthOf(2); + expect(protoObj.extras[0].name).to.equal("extra1"); + expect(protoObj.extras[1].name).to.equal("extra2"); + }); + + it("should convert to protobuf with empty extras", function () { + const props = { + base: 500, + extras: [], + }; + + const feeEstimate = new FeeEstimate(props); + const protoObj = feeEstimate._toProtobuf(); + + expect(protoObj.base.toNumber()).to.equal(500); + expect(protoObj.extras).to.have.lengthOf(0); + }); + + it("should convert to protobuf with zero base", function () { + const props = { + base: 0, + extras: [], + }; + + const feeEstimate = new FeeEstimate(props); + const protoObj = feeEstimate._toProtobuf(); + + expect(protoObj.base.toNumber()).to.equal(0); + expect(protoObj.extras).to.have.lengthOf(0); + }); + + it("should convert to protobuf with large values", function () { + const largeExtra = new FeeExtra({ + name: "largeExtra", + included: 1000000, + count: 2000000, + charged: 1000000, + feePerUnit: Long.fromString("9223372036854775807"), + subtotal: Long.fromString("9223372036854775807"), + }); + + const props = { + base: Long.fromString("9223372036854775807"), + extras: [largeExtra], + }; + + const feeEstimate = new FeeEstimate(props); + const protoObj = feeEstimate._toProtobuf(); + + expect(protoObj.base.toString()).to.equal("9223372036854775807"); + expect(protoObj.extras).to.have.lengthOf(1); + expect(protoObj.extras[0].name).to.equal("largeExtra"); + }); + }); + + describe("round-trip conversion", function () { + it("should maintain data integrity through protobuf conversion", function () { + const original = new FeeEstimate(defaultProps); + const protoObj = original._toProtobuf(); + const converted = FeeEstimate._fromProtobuf(protoObj); + + expect(converted.base.toNumber()).to.equal( + original.base.toNumber(), + ); + expect(converted.extras).to.have.lengthOf(original.extras.length); + expect(converted.extras[0].name).to.equal(original.extras[0].name); + expect(converted.extras[1].name).to.equal(original.extras[1].name); + }); + + it("should handle edge cases in round-trip conversion", function () { + const edgeCases = [ + { + base: 0, + extras: [], + }, + { + base: 1, + extras: [], + }, + { + base: Long.fromString("9223372036854775807"), + extras: [], + }, + { + base: 1000, + extras: [ + new FeeExtra({ + name: "", + included: 0, + count: 0, + charged: 0, + feePerUnit: 0, + subtotal: 0, + }), + ], + }, + ]; + + edgeCases.forEach((props) => { + const original = new FeeEstimate(props); + const protoObj = original._toProtobuf(); + const converted = FeeEstimate._fromProtobuf(protoObj); + + expect(converted.base.toNumber()).to.equal( + original.base.toNumber(), + ); + expect(converted.extras).to.have.lengthOf( + original.extras.length, + ); + }); + }); + }); + + describe("readonly properties", function () { + it("should have readonly properties", function () { + const feeEstimate = new FeeEstimate(defaultProps); + + // Properties should be defined + expect(feeEstimate.base).to.not.be.undefined; + expect(feeEstimate.extras).to.not.be.undefined; + + // Properties should have expected types + expect(feeEstimate.base).to.be.instanceOf(Long); + expect(feeEstimate.extras).to.be.instanceOf(Array); + }); + }); + + describe("extras array handling", function () { + it("should handle multiple extras correctly", function () { + const extras = [ + new FeeExtra({ + name: "extra1", + included: 1, + count: 2, + charged: 1, + feePerUnit: 100, + subtotal: 100, + }), + new FeeExtra({ + name: "extra2", + included: 3, + count: 5, + charged: 2, + feePerUnit: 200, + subtotal: 400, + }), + new FeeExtra({ + name: "extra3", + included: 0, + count: 1, + charged: 1, + feePerUnit: 50, + subtotal: 50, + }), + ]; + + const props = { + base: 1000, + extras: extras, + }; + + const feeEstimate = new FeeEstimate(props); + + expect(feeEstimate.extras).to.have.lengthOf(3); + expect(feeEstimate.extras[0].name).to.equal("extra1"); + expect(feeEstimate.extras[1].name).to.equal("extra2"); + expect(feeEstimate.extras[2].name).to.equal("extra3"); + }); + + it("should preserve extras order", function () { + const extras = [ + new FeeExtra({ + name: "first", + included: 1, + count: 1, + charged: 0, + feePerUnit: 100, + subtotal: 0, + }), + new FeeExtra({ + name: "second", + included: 1, + count: 1, + charged: 0, + feePerUnit: 200, + subtotal: 0, + }), + new FeeExtra({ + name: "third", + included: 1, + count: 1, + charged: 0, + feePerUnit: 300, + subtotal: 0, + }), + ]; + + const props = { + base: 1000, + extras: extras, + }; + + const feeEstimate = new FeeEstimate(props); + + expect(feeEstimate.extras[0].name).to.equal("first"); + expect(feeEstimate.extras[1].name).to.equal("second"); + expect(feeEstimate.extras[2].name).to.equal("third"); + }); + }); +}); diff --git a/test/unit/FeeEstimateMode.js b/test/unit/FeeEstimateMode.js new file mode 100644 index 000000000..4ef568099 --- /dev/null +++ b/test/unit/FeeEstimateMode.js @@ -0,0 +1,82 @@ +import FeeEstimateMode from "../../src/query/enums/FeeEstimateMode.js"; + +describe("FeeEstimateMode", function () { + describe("enum values", function () { + it("should have STATE value of 0", function () { + expect(FeeEstimateMode.STATE).to.equal(0); + }); + + it("should have INTRINSIC value of 1", function () { + expect(FeeEstimateMode.INTRINSIC).to.equal(1); + }); + + it("should be frozen object", function () { + expect(Object.isFrozen(FeeEstimateMode)).to.be.true; + }); + + it("should not allow modification of enum values", function () { + const originalState = FeeEstimateMode.STATE; + const originalIntrinsic = FeeEstimateMode.INTRINSIC; + + // Attempt to modify (should not work due to Object.freeze) + try { + FeeEstimateMode.STATE = 999; + FeeEstimateMode.INTRINSIC = 888; + } catch (e) { + // Expected to throw in strict mode + } + + expect(FeeEstimateMode.STATE).to.equal(originalState); + expect(FeeEstimateMode.INTRINSIC).to.equal(originalIntrinsic); + }); + + it("should have all expected enum keys", function () { + const expectedKeys = ["STATE", "INTRINSIC"]; + const actualKeys = Object.keys(FeeEstimateMode); + + expect(actualKeys).to.have.lengthOf(expectedKeys.length); + expectedKeys.forEach((key) => { + expect(actualKeys).to.include(key); + }); + }); + + it("should have all expected enum values", function () { + const expectedValues = [0, 1]; + const actualValues = Object.values(FeeEstimateMode); + + expect(actualValues).to.have.lengthOf(expectedValues.length); + expectedValues.forEach((value) => { + expect(actualValues).to.include(value); + }); + }); + }); + + describe("usage patterns", function () { + it("should work with Number() conversion", function () { + expect(Number(FeeEstimateMode.STATE)).to.equal(0); + expect(Number(FeeEstimateMode.INTRINSIC)).to.equal(1); + }); + + it("should work with strict equality", function () { + expect(FeeEstimateMode.STATE === 0).to.be.true; + expect(FeeEstimateMode.INTRINSIC === 1).to.be.true; + }); + + it("should work in switch statements", function () { + const testSwitch = (mode) => { + switch (mode) { + case FeeEstimateMode.STATE: + return "state"; + case FeeEstimateMode.INTRINSIC: + return "intrinsic"; + default: + return "unknown"; + } + }; + + expect(testSwitch(FeeEstimateMode.STATE)).to.equal("state"); + expect(testSwitch(FeeEstimateMode.INTRINSIC)).to.equal("intrinsic"); + expect(testSwitch(999)).to.equal("unknown"); + }); + }); +}); diff --git a/test/unit/FeeEstimateQuery.js b/test/unit/FeeEstimateQuery.js new file mode 100644 index 000000000..6da4a558a --- /dev/null +++ b/test/unit/FeeEstimateQuery.js @@ -0,0 +1,127 @@ +import FeeEstimateQuery from "../../src/query/FeeEstimateQuery.js"; +import FeeEstimateMode from "../../src/query/enums/FeeEstimateMode.js"; +import { TransferTransaction, AccountId, Hbar } from "../../src/index.js"; + +describe("FeeEstimateQuery", function () { + let mockTransaction; + + beforeEach(function () { + // Create a mock transaction + mockTransaction = new TransferTransaction() + .addHbarTransfer(new AccountId(1001), new Hbar(-10)) + .addHbarTransfer(new AccountId(1002), new Hbar(10)); + }); + + describe("constructor", function () { + it("should create with default props", function () { + const query = new FeeEstimateQuery(); + + expect(query.mode).to.equal(FeeEstimateMode.STATE); + expect(query.transaction).to.be.null; + }); + + it("should create with mode prop", function () { + const query = new FeeEstimateQuery({ + mode: FeeEstimateMode.INTRINSIC, + }); + + expect(query.mode).to.equal(FeeEstimateMode.INTRINSIC); + expect(query.transaction).to.be.null; + }); + + it("should create with transaction prop", function () { + const query = new FeeEstimateQuery({ + transaction: mockTransaction, + }); + + expect(query.mode).to.equal(FeeEstimateMode.STATE); + expect(query.transaction).to.equal(mockTransaction); + }); + }); + + describe("setMode", function () { + it("should set STATE mode", function () { + const query = new FeeEstimateQuery(); + const result = query.setMode(FeeEstimateMode.STATE); + + expect(query.mode).to.equal(FeeEstimateMode.STATE); + expect(result).to.equal(query); + }); + + it("should set INTRINSIC mode", function () { + const query = new FeeEstimateQuery(); + const result = query.setMode(FeeEstimateMode.INTRINSIC); + + expect(query.mode).to.equal(FeeEstimateMode.INTRINSIC); + expect(result).to.equal(query); + }); + + it("should throw error for invalid mode", function () { + const query = new FeeEstimateQuery(); + + expect(() => query.setMode(999)).to.throw( + Error, + "Invalid FeeEstimateMode: 999. Must be one of: STATE (0), INTRINSIC (1)", + ); + }); + }); + + describe("setTransaction", function () { + it("should set transaction", function () { + const query = new FeeEstimateQuery(); + const result = query.setTransaction(mockTransaction); + + expect(query.transaction).to.equal(mockTransaction); + expect(result).to.equal(query); + }); + + it("should set null transaction", function () { + const query = new FeeEstimateQuery(); + query.setTransaction(mockTransaction); + query.setTransaction(null); + + expect(query.transaction).to.be.null; + }); + }); + + describe("getMode", function () { + it("should get current mode", function () { + const query = new FeeEstimateQuery(); + query.setMode(FeeEstimateMode.INTRINSIC); + + expect(query.getMode()).to.equal(FeeEstimateMode.INTRINSIC); + }); + }); + + describe("getTransaction", function () { + it("should get current transaction", function () { + const query = new FeeEstimateQuery(); + query.setTransaction(mockTransaction); + + expect(query.getTransaction()).to.equal(mockTransaction); + }); + }); + + describe("_validateChecksums", function () { + it("should validate transaction checksums when transaction is set", function () { + const query = new FeeEstimateQuery({ + transaction: mockTransaction, + }); + + let validated = false; + mockTransaction._validateChecksums = () => { + validated = true; + }; + + query._validateChecksums({}); + + expect(validated).to.be.true; + }); + + it("should not validate when transaction is null", function () { + const query = new FeeEstimateQuery(); + + expect(() => query._validateChecksums({})).to.not.throw(); + }); + }); +}); diff --git a/test/unit/FeeEstimateResponse.js b/test/unit/FeeEstimateResponse.js new file mode 100644 index 000000000..3a66f9f29 --- /dev/null +++ b/test/unit/FeeEstimateResponse.js @@ -0,0 +1,514 @@ +import FeeEstimateResponse from "../../src/query/FeeEstimateResponse.js"; +import FeeEstimateMode from "../../src/query/enums/FeeEstimateMode.js"; +import NetworkFee from "../../src/query/NetworkFee.js"; +import FeeEstimate from "../../src/query/FeeEstimate.js"; +import FeeExtra from "../../src/query/FeeExtra.js"; +import Long from "long"; + +describe("FeeEstimateResponse", function () { + let defaultProps; + let mockNetworkFee; + let mockNodeFee; + let mockServiceFee; + + beforeEach(function () { + mockNetworkFee = new NetworkFee({ + multiplier: 2.0, + subtotal: Long.fromNumber(1000), + }); + + mockNodeFee = new FeeEstimate({ + base: Long.fromNumber(500), + extras: [ + new FeeExtra({ + name: "nodeExtra", + included: 2, + count: 5, + charged: 3, + feePerUnit: 100, + subtotal: 300, + }), + ], + }); + + mockServiceFee = new FeeEstimate({ + base: Long.fromNumber(750), + extras: [ + new FeeExtra({ + name: "serviceExtra", + included: 1, + count: 3, + charged: 2, + feePerUnit: 50, + subtotal: 100, + }), + ], + }); + + defaultProps = { + mode: FeeEstimateMode.STATE, + networkFee: mockNetworkFee, + nodeFee: mockNodeFee, + serviceFee: mockServiceFee, + notes: ["Test note 1", "Test note 2"], + total: Long.fromNumber(2250), + }; + }); + + describe("constructor", function () { + it("should create with all required props", function () { + const response = new FeeEstimateResponse(defaultProps); + + expect(response.mode).to.equal(FeeEstimateMode.STATE); + expect(response.networkFee).to.equal(mockNetworkFee); + expect(response.nodeFee).to.equal(mockNodeFee); + expect(response.serviceFee).to.equal(mockServiceFee); + expect(response.notes).to.deep.equal([ + "Test note 1", + "Test note 2", + ]); + expect(response.total.toNumber()).to.equal(2250); + }); + + it("should handle INTRINSIC mode", function () { + const props = { + ...defaultProps, + mode: FeeEstimateMode.INTRINSIC, + }; + + const response = new FeeEstimateResponse(props); + + expect(response.mode).to.equal(FeeEstimateMode.INTRINSIC); + }); + + it("should handle empty notes array", function () { + const props = { + ...defaultProps, + notes: [], + }; + + const response = new FeeEstimateResponse(props); + + expect(response.notes).to.deep.equal([]); + }); + + it("should handle undefined notes", function () { + const props = { + ...defaultProps, + notes: undefined, + }; + + const response = new FeeEstimateResponse(props); + + expect(response.notes).to.deep.equal([]); + }); + + it("should handle number values for total", function () { + const props = { + ...defaultProps, + total: 3000, + }; + + const response = new FeeEstimateResponse(props); + + expect(response.total.toNumber()).to.equal(3000); + }); + + it("should handle string values for total", function () { + const props = { + ...defaultProps, + total: "4000", + }; + + const response = new FeeEstimateResponse(props); + + expect(response.total.toNumber()).to.equal(4000); + }); + + it("should handle zero total", function () { + const props = { + ...defaultProps, + total: 0, + }; + + const response = new FeeEstimateResponse(props); + + expect(response.total.toNumber()).to.equal(0); + }); + + it("should handle single note", function () { + const props = { + ...defaultProps, + notes: ["Single note"], + }; + + const response = new FeeEstimateResponse(props); + + expect(response.notes).to.deep.equal(["Single note"]); + }); + }); + + describe("_fromProtobuf", function () { + it("should create from protobuf with all fields", function () { + const protoObj = { + mode: FeeEstimateMode.INTRINSIC, + network: { + multiplier: 1.5, + subtotal: Long.fromNumber(800), + }, + node: { + base: Long.fromNumber(400), + extras: [ + { + name: "protoNodeExtra", + included: 1, + count: 2, + charged: 1, + feePerUnit: 200, + subtotal: 200, + }, + ], + }, + service: { + base: Long.fromNumber(600), + extras: [], + }, + notes: ["Proto note 1", "Proto note 2"], + total: Long.fromNumber(1800), + }; + + const response = FeeEstimateResponse._fromProtobuf(protoObj); + + expect(response.mode).to.equal(FeeEstimateMode.INTRINSIC); + expect(response.networkFee.multiplier).to.equal(1.5); + expect(response.networkFee.subtotal.toNumber()).to.equal(800); + expect(response.nodeFee.base.toNumber()).to.equal(400); + expect(response.nodeFee.extras).to.have.lengthOf(1); + expect(response.nodeFee.extras[0].name).to.equal("protoNodeExtra"); + expect(response.serviceFee.base.toNumber()).to.equal(600); + expect(response.serviceFee.extras).to.have.lengthOf(0); + expect(response.notes).to.deep.equal([ + "Proto note 1", + "Proto note 2", + ]); + expect(response.total.toNumber()).to.equal(1800); + }); + + it("should handle missing fields in protobuf", function () { + const protoObj = { + mode: FeeEstimateMode.STATE, + total: Long.fromNumber(1000), + }; + + const response = FeeEstimateResponse._fromProtobuf(protoObj); + + expect(response.mode).to.equal(FeeEstimateMode.STATE); + expect(response.networkFee.multiplier).to.equal(0); + expect(response.networkFee.subtotal.toNumber()).to.equal(0); + expect(response.nodeFee.base.toNumber()).to.equal(0); + expect(response.nodeFee.extras).to.have.lengthOf(0); + expect(response.serviceFee.base.toNumber()).to.equal(0); + expect(response.serviceFee.extras).to.have.lengthOf(0); + expect(response.notes).to.deep.equal([]); + expect(response.total.toNumber()).to.equal(1000); + }); + + it("should handle null/undefined fields in protobuf", function () { + const protoObj = { + mode: null, + network: null, + node: null, + service: null, + notes: null, + total: null, + }; + + const response = FeeEstimateResponse._fromProtobuf(protoObj); + + expect(response.mode).to.equal(FeeEstimateMode.STATE); + expect(response.networkFee.multiplier).to.equal(0); + expect(response.networkFee.subtotal.toNumber()).to.equal(0); + expect(response.nodeFee.base.toNumber()).to.equal(0); + expect(response.nodeFee.extras).to.have.lengthOf(0); + expect(response.serviceFee.base.toNumber()).to.equal(0); + expect(response.serviceFee.extras).to.have.lengthOf(0); + expect(response.notes).to.deep.equal([]); + expect(response.total.toNumber()).to.equal(0); + }); + + it("should handle empty protobuf object", function () { + const protoObj = {}; + + const response = FeeEstimateResponse._fromProtobuf(protoObj); + + expect(response.mode).to.equal(FeeEstimateMode.STATE); + expect(response.networkFee.multiplier).to.equal(0); + expect(response.networkFee.subtotal.toNumber()).to.equal(0); + expect(response.nodeFee.base.toNumber()).to.equal(0); + expect(response.nodeFee.extras).to.have.lengthOf(0); + expect(response.serviceFee.base.toNumber()).to.equal(0); + expect(response.serviceFee.extras).to.have.lengthOf(0); + expect(response.notes).to.deep.equal([]); + expect(response.total.toNumber()).to.equal(0); + }); + + it("should default to STATE mode when mode is null", function () { + const protoObj = { + mode: null, + total: Long.fromNumber(1000), + }; + + const response = FeeEstimateResponse._fromProtobuf(protoObj); + + expect(response.mode).to.equal(FeeEstimateMode.STATE); + }); + }); + + describe("_toProtobuf", function () { + it("should convert to protobuf with all fields", function () { + const response = new FeeEstimateResponse(defaultProps); + const protoObj = response._toProtobuf(); + + expect(protoObj.mode).to.equal(FeeEstimateMode.STATE); + expect(protoObj.network.multiplier).to.equal(2.0); + expect(protoObj.network.subtotal.toNumber()).to.equal(1000); + expect(protoObj.node.base.toNumber()).to.equal(500); + expect(protoObj.node.extras).to.have.lengthOf(1); + expect(protoObj.node.extras[0].name).to.equal("nodeExtra"); + expect(protoObj.service.base.toNumber()).to.equal(750); + expect(protoObj.service.extras).to.have.lengthOf(1); + expect(protoObj.service.extras[0].name).to.equal("serviceExtra"); + expect(protoObj.notes).to.deep.equal([ + "Test note 1", + "Test note 2", + ]); + expect(protoObj.total.toNumber()).to.equal(2250); + }); + + it("should convert to protobuf with empty notes", function () { + const props = { + ...defaultProps, + notes: [], + }; + + const response = new FeeEstimateResponse(props); + const protoObj = response._toProtobuf(); + + expect(protoObj.notes).to.deep.equal([]); + }); + + it("should convert to protobuf with zero values", function () { + const zeroNetworkFee = new NetworkFee({ + multiplier: 0, + subtotal: 0, + }); + + const zeroNodeFee = new FeeEstimate({ + base: 0, + extras: [], + }); + + const zeroServiceFee = new FeeEstimate({ + base: 0, + extras: [], + }); + + const props = { + mode: FeeEstimateMode.STATE, + networkFee: zeroNetworkFee, + nodeFee: zeroNodeFee, + serviceFee: zeroServiceFee, + notes: [], + total: 0, + }; + + const response = new FeeEstimateResponse(props); + const protoObj = response._toProtobuf(); + + expect(protoObj.mode).to.equal(FeeEstimateMode.STATE); + expect(protoObj.network.multiplier).to.equal(0); + expect(protoObj.network.subtotal.toNumber()).to.equal(0); + expect(protoObj.node.base.toNumber()).to.equal(0); + expect(protoObj.node.extras).to.have.lengthOf(0); + expect(protoObj.service.base.toNumber()).to.equal(0); + expect(protoObj.service.extras).to.have.lengthOf(0); + expect(protoObj.notes).to.deep.equal([]); + expect(protoObj.total.toNumber()).to.equal(0); + }); + }); + + describe("round-trip conversion", function () { + it("should maintain data integrity through protobuf conversion", function () { + const original = new FeeEstimateResponse(defaultProps); + const protoObj = original._toProtobuf(); + const converted = FeeEstimateResponse._fromProtobuf(protoObj); + + expect(converted.mode).to.equal(original.mode); + expect(converted.networkFee.multiplier).to.equal( + original.networkFee.multiplier, + ); + expect(converted.networkFee.subtotal.toNumber()).to.equal( + original.networkFee.subtotal.toNumber(), + ); + expect(converted.nodeFee.base.toNumber()).to.equal( + original.nodeFee.base.toNumber(), + ); + expect(converted.nodeFee.extras).to.have.lengthOf( + original.nodeFee.extras.length, + ); + expect(converted.serviceFee.base.toNumber()).to.equal( + original.serviceFee.base.toNumber(), + ); + expect(converted.serviceFee.extras).to.have.lengthOf( + original.serviceFee.extras.length, + ); + expect(converted.notes).to.deep.equal(original.notes); + expect(converted.total.toNumber()).to.equal( + original.total.toNumber(), + ); + }); + + it("should handle edge cases in round-trip conversion", function () { + const edgeCases = [ + { + mode: FeeEstimateMode.STATE, + networkFee: new NetworkFee({ multiplier: 0, subtotal: 0 }), + nodeFee: new FeeEstimate({ base: 0, extras: [] }), + serviceFee: new FeeEstimate({ base: 0, extras: [] }), + notes: [], + total: 0, + }, + { + mode: FeeEstimateMode.INTRINSIC, + networkFee: new NetworkFee({ multiplier: 1, subtotal: 1 }), + nodeFee: new FeeEstimate({ base: 1, extras: [] }), + serviceFee: new FeeEstimate({ base: 1, extras: [] }), + notes: ["Single note"], + total: 1, + }, + ]; + + edgeCases.forEach((props) => { + const original = new FeeEstimateResponse(props); + const protoObj = original._toProtobuf(); + const converted = FeeEstimateResponse._fromProtobuf(protoObj); + + expect(converted.mode).to.equal(original.mode); + expect(converted.total.toNumber()).to.equal( + original.total.toNumber(), + ); + expect(converted.notes).to.deep.equal(original.notes); + }); + }); + }); + + describe("readonly properties", function () { + it("should have readonly properties", function () { + const response = new FeeEstimateResponse(defaultProps); + + // Properties should be defined + expect(response.mode).to.not.be.undefined; + expect(response.networkFee).to.not.be.undefined; + expect(response.nodeFee).to.not.be.undefined; + expect(response.serviceFee).to.not.be.undefined; + expect(response.notes).to.not.be.undefined; + expect(response.total).to.not.be.undefined; + + // Properties should have expected types + expect(typeof response.mode).to.equal("number"); + expect(response.networkFee).to.be.instanceOf(NetworkFee); + expect(response.nodeFee).to.be.instanceOf(FeeEstimate); + expect(response.serviceFee).to.be.instanceOf(FeeEstimate); + expect(response.notes).to.be.instanceOf(Array); + expect(response.total).to.be.instanceOf(Long); + }); + }); + + describe("notes handling", function () { + it("should handle multiple notes correctly", function () { + const notes = [ + "First note", + "Second note", + "Third note", + "Fourth note", + ]; + + const props = { + ...defaultProps, + notes: notes, + }; + + const response = new FeeEstimateResponse(props); + + expect(response.notes).to.have.lengthOf(4); + expect(response.notes[0]).to.equal("First note"); + expect(response.notes[1]).to.equal("Second note"); + expect(response.notes[2]).to.equal("Third note"); + expect(response.notes[3]).to.equal("Fourth note"); + }); + + it("should handle empty string notes", function () { + const props = { + ...defaultProps, + notes: ["", "Valid note", ""], + }; + + const response = new FeeEstimateResponse(props); + + expect(response.notes).to.have.lengthOf(3); + expect(response.notes[0]).to.equal(""); + expect(response.notes[1]).to.equal("Valid note"); + expect(response.notes[2]).to.equal(""); + }); + + it("should preserve notes order", function () { + const notes = ["First", "Second", "Third"]; + + const props = { + ...defaultProps, + notes: notes, + }; + + const response = new FeeEstimateResponse(props); + + expect(response.notes[0]).to.equal("First"); + expect(response.notes[1]).to.equal("Second"); + expect(response.notes[2]).to.equal("Third"); + }); + }); + + describe("mode handling", function () { + it("should handle STATE mode", function () { + const props = { + ...defaultProps, + mode: FeeEstimateMode.STATE, + }; + + const response = new FeeEstimateResponse(props); + + expect(response.mode).to.equal(FeeEstimateMode.STATE); + }); + + it("should handle INTRINSIC mode", function () { + const props = { + ...defaultProps, + mode: FeeEstimateMode.INTRINSIC, + }; + + const response = new FeeEstimateResponse(props); + + expect(response.mode).to.equal(FeeEstimateMode.INTRINSIC); + }); + + it("should handle numeric mode values", function () { + const props = { + ...defaultProps, + mode: 0, // STATE + }; + + const response = new FeeEstimateResponse(props); + + expect(response.mode).to.equal(0); + }); + }); +}); diff --git a/test/unit/FeeExtra.js b/test/unit/FeeExtra.js new file mode 100644 index 000000000..83555fb7e --- /dev/null +++ b/test/unit/FeeExtra.js @@ -0,0 +1,306 @@ +import FeeExtra from "../../src/query/FeeExtra.js"; +import Long from "long"; + +describe("FeeExtra", function () { + let defaultProps; + + beforeEach(function () { + defaultProps = { + name: "testFee", + included: 5, + count: 10, + charged: 5, + feePerUnit: Long.fromNumber(100), + subtotal: Long.fromNumber(500), + }; + }); + + describe("constructor", function () { + it("should create with all required props", function () { + const feeExtra = new FeeExtra(defaultProps); + + expect(feeExtra.name).to.equal("testFee"); + expect(feeExtra.included).to.equal(5); + expect(feeExtra.count).to.equal(10); + expect(feeExtra.charged).to.equal(5); + expect(feeExtra.feePerUnit.toNumber()).to.equal(100); + expect(feeExtra.subtotal.toNumber()).to.equal(500); + }); + + it("should handle number values for Long fields", function () { + const props = { + ...defaultProps, + feePerUnit: 200, + subtotal: 1000, + }; + + const feeExtra = new FeeExtra(props); + + expect(feeExtra.feePerUnit.toNumber()).to.equal(200); + expect(feeExtra.subtotal.toNumber()).to.equal(1000); + }); + + it("should handle string values for Long fields", function () { + const props = { + ...defaultProps, + feePerUnit: "300", + subtotal: "1500", + }; + + const feeExtra = new FeeExtra(props); + + expect(feeExtra.feePerUnit.toNumber()).to.equal(300); + expect(feeExtra.subtotal.toNumber()).to.equal(1500); + }); + + it("should handle empty string for name", function () { + const props = { + ...defaultProps, + name: "", + }; + + const feeExtra = new FeeExtra(props); + + expect(feeExtra.name).to.equal(""); + }); + + it("should handle zero values", function () { + const props = { + name: "zeroFee", + included: 0, + count: 0, + charged: 0, + feePerUnit: 0, + subtotal: 0, + }; + + const feeExtra = new FeeExtra(props); + + expect(feeExtra.name).to.equal("zeroFee"); + expect(feeExtra.included).to.equal(0); + expect(feeExtra.count).to.equal(0); + expect(feeExtra.charged).to.equal(0); + expect(feeExtra.feePerUnit.toNumber()).to.equal(0); + expect(feeExtra.subtotal.toNumber()).to.equal(0); + }); + }); + + describe("_fromProtobuf", function () { + it("should create from protobuf with all fields", function () { + const protoObj = { + name: "protobufFee", + included: 3, + count: 8, + charged: 5, + feePerUnit: Long.fromNumber(150), + subtotal: Long.fromNumber(750), + }; + + const feeExtra = FeeExtra._fromProtobuf(protoObj); + + expect(feeExtra.name).to.equal("protobufFee"); + expect(feeExtra.included).to.equal(3); + expect(feeExtra.count).to.equal(8); + expect(feeExtra.charged).to.equal(5); + expect(feeExtra.feePerUnit.toNumber()).to.equal(150); + expect(feeExtra.subtotal.toNumber()).to.equal(750); + }); + + it("should handle missing fields in protobuf", function () { + const protoObj = { + name: "partialFee", + included: 2, + }; + + const feeExtra = FeeExtra._fromProtobuf(protoObj); + + expect(feeExtra.name).to.equal("partialFee"); + expect(feeExtra.included).to.equal(2); + expect(feeExtra.count).to.equal(0); + expect(feeExtra.charged).to.equal(0); + expect(feeExtra.feePerUnit.toNumber()).to.equal(0); + expect(feeExtra.subtotal.toNumber()).to.equal(0); + }); + + it("should handle null/undefined fields in protobuf", function () { + const protoObj = { + name: null, + included: undefined, + count: null, + charged: undefined, + feePerUnit: null, + subtotal: undefined, + }; + + const feeExtra = FeeExtra._fromProtobuf(protoObj); + + expect(feeExtra.name).to.equal(""); + expect(feeExtra.included).to.equal(0); + expect(feeExtra.count).to.equal(0); + expect(feeExtra.charged).to.equal(0); + expect(feeExtra.feePerUnit.toNumber()).to.equal(0); + expect(feeExtra.subtotal.toNumber()).to.equal(0); + }); + + it("should handle empty protobuf object", function () { + const protoObj = {}; + + const feeExtra = FeeExtra._fromProtobuf(protoObj); + + expect(feeExtra.name).to.equal(""); + expect(feeExtra.included).to.equal(0); + expect(feeExtra.count).to.equal(0); + expect(feeExtra.charged).to.equal(0); + expect(feeExtra.feePerUnit.toNumber()).to.equal(0); + expect(feeExtra.subtotal.toNumber()).to.equal(0); + }); + }); + + describe("_toProtobuf", function () { + it("should convert to protobuf with all fields", function () { + const feeExtra = new FeeExtra(defaultProps); + const protoObj = feeExtra._toProtobuf(); + + expect(protoObj.name).to.equal("testFee"); + expect(protoObj.included).to.equal(5); + expect(protoObj.count).to.equal(10); + expect(protoObj.charged).to.equal(5); + expect(protoObj.feePerUnit.toNumber()).to.equal(100); + expect(protoObj.subtotal.toNumber()).to.equal(500); + }); + + it("should convert to protobuf with zero values", function () { + const props = { + name: "zeroFee", + included: 0, + count: 0, + charged: 0, + feePerUnit: 0, + subtotal: 0, + }; + + const feeExtra = new FeeExtra(props); + const protoObj = feeExtra._toProtobuf(); + + expect(protoObj.name).to.equal("zeroFee"); + expect(protoObj.included).to.equal(0); + expect(protoObj.count).to.equal(0); + expect(protoObj.charged).to.equal(0); + expect(protoObj.feePerUnit.toNumber()).to.equal(0); + expect(protoObj.subtotal.toNumber()).to.equal(0); + }); + + it("should convert to protobuf with large values", function () { + const props = { + name: "largeFee", + included: 1000000, + count: 2000000, + charged: 1000000, + feePerUnit: Long.fromString("9223372036854775807"), + subtotal: Long.fromString("9223372036854775807"), + }; + + const feeExtra = new FeeExtra(props); + const protoObj = feeExtra._toProtobuf(); + + expect(protoObj.name).to.equal("largeFee"); + expect(protoObj.included).to.equal(1000000); + expect(protoObj.count).to.equal(2000000); + expect(protoObj.charged).to.equal(1000000); + expect(protoObj.feePerUnit.toString()).to.equal( + "9223372036854775807", + ); + expect(protoObj.subtotal.toString()).to.equal( + "9223372036854775807", + ); + }); + }); + + describe("round-trip conversion", function () { + it("should maintain data integrity through protobuf conversion", function () { + const original = new FeeExtra(defaultProps); + const protoObj = original._toProtobuf(); + const converted = FeeExtra._fromProtobuf(protoObj); + + expect(converted.name).to.equal(original.name); + expect(converted.included).to.equal(original.included); + expect(converted.count).to.equal(original.count); + expect(converted.charged).to.equal(original.charged); + expect(converted.feePerUnit.toNumber()).to.equal( + original.feePerUnit.toNumber(), + ); + expect(converted.subtotal.toNumber()).to.equal( + original.subtotal.toNumber(), + ); + }); + + it("should handle edge cases in round-trip conversion", function () { + const edgeCases = [ + { + name: "", + included: 0, + count: 0, + charged: 0, + feePerUnit: 0, + subtotal: 0, + }, + { + name: "a".repeat(1000), // Very long name + included: 1, + count: 1, + charged: 0, + feePerUnit: 1, + subtotal: 0, + }, + { + name: "special-chars-!@#$%^&*()", + included: 999999, + count: 999999, + charged: 0, + feePerUnit: 999999, + subtotal: 0, + }, + ]; + + edgeCases.forEach((props) => { + const original = new FeeExtra(props); + const protoObj = original._toProtobuf(); + const converted = FeeExtra._fromProtobuf(protoObj); + + expect(converted.name).to.equal(original.name); + expect(converted.included).to.equal(original.included); + expect(converted.count).to.equal(original.count); + expect(converted.charged).to.equal(original.charged); + expect(converted.feePerUnit.toNumber()).to.equal( + original.feePerUnit.toNumber(), + ); + expect(converted.subtotal.toNumber()).to.equal( + original.subtotal.toNumber(), + ); + }); + }); + }); + + describe("readonly properties", function () { + it("should have readonly properties", function () { + const feeExtra = new FeeExtra(defaultProps); + + // Properties should be defined + expect(feeExtra.name).to.not.be.undefined; + expect(feeExtra.included).to.not.be.undefined; + expect(feeExtra.count).to.not.be.undefined; + expect(feeExtra.charged).to.not.be.undefined; + expect(feeExtra.feePerUnit).to.not.be.undefined; + expect(feeExtra.subtotal).to.not.be.undefined; + + // Properties should not be writable (though this depends on implementation) + // This test verifies the properties exist and have expected values + expect(typeof feeExtra.name).to.equal("string"); + expect(typeof feeExtra.included).to.equal("number"); + expect(typeof feeExtra.count).to.equal("number"); + expect(typeof feeExtra.charged).to.equal("number"); + expect(feeExtra.feePerUnit).to.be.instanceOf(Long); + expect(feeExtra.subtotal).to.be.instanceOf(Long); + }); + }); +}); diff --git a/test/unit/NetworkFee.js b/test/unit/NetworkFee.js new file mode 100644 index 000000000..d37d55a0d --- /dev/null +++ b/test/unit/NetworkFee.js @@ -0,0 +1,286 @@ +import NetworkFee from "../../src/query/NetworkFee.js"; +import Long from "long"; + +describe("NetworkFee", function () { + let defaultProps; + + beforeEach(function () { + defaultProps = { + multiplier: 2.5, + subtotal: Long.fromNumber(1000), + }; + }); + + describe("constructor", function () { + it("should create with all required props", function () { + const networkFee = new NetworkFee(defaultProps); + + expect(networkFee.multiplier).to.equal(2.5); + expect(networkFee.subtotal.toNumber()).to.equal(1000); + }); + + it("should handle number values for subtotal", function () { + const props = { + multiplier: 1.0, + subtotal: 500, + }; + + const networkFee = new NetworkFee(props); + + expect(networkFee.multiplier).to.equal(1.0); + expect(networkFee.subtotal.toNumber()).to.equal(500); + }); + + it("should handle string values for subtotal", function () { + const props = { + multiplier: 3.14, + subtotal: "1500", + }; + + const networkFee = new NetworkFee(props); + + expect(networkFee.multiplier).to.equal(3.14); + expect(networkFee.subtotal.toNumber()).to.equal(1500); + }); + + it("should handle zero values", function () { + const props = { + multiplier: 0, + subtotal: 0, + }; + + const networkFee = new NetworkFee(props); + + expect(networkFee.multiplier).to.equal(0); + expect(networkFee.subtotal.toNumber()).to.equal(0); + }); + + it("should handle negative multiplier", function () { + const props = { + multiplier: -1.5, + subtotal: 100, + }; + + const networkFee = new NetworkFee(props); + + expect(networkFee.multiplier).to.equal(-1.5); + expect(networkFee.subtotal.toNumber()).to.equal(100); + }); + + it("should handle decimal multiplier", function () { + const props = { + multiplier: 0.5, + subtotal: 200, + }; + + const networkFee = new NetworkFee(props); + + expect(networkFee.multiplier).to.equal(0.5); + expect(networkFee.subtotal.toNumber()).to.equal(200); + }); + }); + + describe("_fromProtobuf", function () { + it("should create from protobuf with all fields", function () { + const protoObj = { + multiplier: 1.75, + subtotal: Long.fromNumber(750), + }; + + const networkFee = NetworkFee._fromProtobuf(protoObj); + + expect(networkFee.multiplier).to.equal(1.75); + expect(networkFee.subtotal.toNumber()).to.equal(750); + }); + + it("should handle missing fields in protobuf", function () { + const protoObj = { + multiplier: 2.0, + }; + + const networkFee = NetworkFee._fromProtobuf(protoObj); + + expect(networkFee.multiplier).to.equal(2.0); + expect(networkFee.subtotal.toNumber()).to.equal(0); + }); + + it("should handle null/undefined fields in protobuf", function () { + const protoObj = { + multiplier: null, + subtotal: undefined, + }; + + const networkFee = NetworkFee._fromProtobuf(protoObj); + + expect(networkFee.multiplier).to.equal(0); + expect(networkFee.subtotal.toNumber()).to.equal(0); + }); + + it("should handle empty protobuf object", function () { + const protoObj = {}; + + const networkFee = NetworkFee._fromProtobuf(protoObj); + + expect(networkFee.multiplier).to.equal(0); + expect(networkFee.subtotal.toNumber()).to.equal(0); + }); + + it("should handle protobuf with zero values", function () { + const protoObj = { + multiplier: 0, + subtotal: Long.fromNumber(0), + }; + + const networkFee = NetworkFee._fromProtobuf(protoObj); + + expect(networkFee.multiplier).to.equal(0); + expect(networkFee.subtotal.toNumber()).to.equal(0); + }); + }); + + describe("_toProtobuf", function () { + it("should convert to protobuf with all fields", function () { + const networkFee = new NetworkFee(defaultProps); + const protoObj = networkFee._toProtobuf(); + + expect(protoObj.multiplier).to.equal(2.5); + expect(protoObj.subtotal.toNumber()).to.equal(1000); + }); + + it("should convert to protobuf with zero values", function () { + const props = { + multiplier: 0, + subtotal: 0, + }; + + const networkFee = new NetworkFee(props); + const protoObj = networkFee._toProtobuf(); + + expect(protoObj.multiplier).to.equal(0); + expect(protoObj.subtotal.toNumber()).to.equal(0); + }); + + it("should convert to protobuf with large values", function () { + const props = { + multiplier: 999.999, + subtotal: Long.fromString("9223372036854775807"), + }; + + const networkFee = new NetworkFee(props); + const protoObj = networkFee._toProtobuf(); + + expect(protoObj.multiplier).to.equal(999.999); + expect(protoObj.subtotal.toString()).to.equal( + "9223372036854775807", + ); + }); + + it("should convert to protobuf with negative multiplier", function () { + const props = { + multiplier: -2.5, + subtotal: 100, + }; + + const networkFee = new NetworkFee(props); + const protoObj = networkFee._toProtobuf(); + + expect(protoObj.multiplier).to.equal(-2.5); + expect(protoObj.subtotal.toNumber()).to.equal(100); + }); + }); + + describe("round-trip conversion", function () { + it("should maintain data integrity through protobuf conversion", function () { + const original = new NetworkFee(defaultProps); + const protoObj = original._toProtobuf(); + const converted = NetworkFee._fromProtobuf(protoObj); + + expect(converted.multiplier).to.equal(original.multiplier); + expect(converted.subtotal.toNumber()).to.equal( + original.subtotal.toNumber(), + ); + }); + + it("should handle edge cases in round-trip conversion", function () { + const edgeCases = [ + { + multiplier: 0, + subtotal: 0, + }, + { + multiplier: 1, + subtotal: 1, + }, + { + multiplier: 0.001, + subtotal: 1, + }, + { + multiplier: 999.999, + subtotal: Long.fromString("9223372036854775807"), + }, + { + multiplier: -1.5, + subtotal: 100, + }, + ]; + + edgeCases.forEach((props) => { + const original = new NetworkFee(props); + const protoObj = original._toProtobuf(); + const converted = NetworkFee._fromProtobuf(protoObj); + + expect(converted.multiplier).to.equal(original.multiplier); + expect(converted.subtotal.toNumber()).to.equal( + original.subtotal.toNumber(), + ); + }); + }); + }); + + describe("readonly properties", function () { + it("should have readonly properties", function () { + const networkFee = new NetworkFee(defaultProps); + + // Properties should be defined + expect(networkFee.multiplier).to.not.be.undefined; + expect(networkFee.subtotal).to.not.be.undefined; + + // Properties should have expected types + expect(typeof networkFee.multiplier).to.equal("number"); + expect(networkFee.subtotal).to.be.instanceOf(Long); + }); + }); + + describe("mathematical operations", function () { + it("should handle multiplier calculations correctly", function () { + const testCases = [ + { multiplier: 1.0, subtotal: 100, expected: 100 }, + { multiplier: 2.0, subtotal: 100, expected: 200 }, + { multiplier: 0.5, subtotal: 100, expected: 50 }, + { multiplier: 0, subtotal: 100, expected: 0 }, + { multiplier: -1.0, subtotal: 100, expected: -100 }, + ]; + + testCases.forEach(({ multiplier, subtotal, expected }) => { + const networkFee = new NetworkFee({ multiplier, subtotal }); + const calculatedTotal = + networkFee.subtotal.toNumber() * multiplier; + + expect(calculatedTotal).to.equal(expected); + }); + }); + + it("should handle precision in multiplier", function () { + const props = { + multiplier: 1.23456789, + subtotal: 1000, + }; + + const networkFee = new NetworkFee(props); + + expect(networkFee.multiplier).to.equal(1.23456789); + expect(networkFee.subtotal.toNumber()).to.equal(1000); + }); + }); +});