diff --git a/examples/src/main/java/com/hedera/hashgraph/sdk/examples/FeeEstimateQueryExample.java b/examples/src/main/java/com/hedera/hashgraph/sdk/examples/FeeEstimateQueryExample.java new file mode 100644 index 0000000000..e74f414764 --- /dev/null +++ b/examples/src/main/java/com/hedera/hashgraph/sdk/examples/FeeEstimateQueryExample.java @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.hedera.hashgraph.sdk.examples; + +import com.hedera.hashgraph.sdk.*; +import com.hedera.hashgraph.sdk.logger.LogLevel; +import com.hedera.hashgraph.sdk.logger.Logger; +import io.github.cdimascio.dotenv.Dotenv; +import java.util.Objects; + +/** + * How to estimate transaction fees using the mirror node's fee estimation service. + *
+ * This example demonstrates: + * 1. Creating and freezing a transfer transaction + * 2. Estimating fees with STATE mode (considers current network state) + * 3. Estimating fees with INTRINSIC mode (only transaction properties) + * 4. Displaying detailed fee breakdowns + */ +class FeeEstimateQueryExample { + + /* + * See .env.sample in the examples folder root for how to specify values below + * or set environment variables with the same names. + */ + + /** + * Operator's account ID. + * Used to sign and pay for operations on Hedera. + */ + private static final AccountId OPERATOR_ID = + AccountId.fromString(Objects.requireNonNull(Dotenv.load().get("OPERATOR_ID"))); + + /** + * Operator's private key. + */ + private static final PrivateKey OPERATOR_KEY = + PrivateKey.fromString(Objects.requireNonNull(Dotenv.load().get("OPERATOR_KEY"))); + + /** + * HEDERA_NETWORK defaults to testnet if not specified in dotenv file. + * Network can be: localhost, testnet, previewnet or mainnet. + */ + private static final String HEDERA_NETWORK = Dotenv.load().get("HEDERA_NETWORK", "testnet"); + + /** + * SDK_LOG_LEVEL defaults to SILENT if not specified in dotenv file. + * Log levels can be: TRACE, DEBUG, INFO, WARN, ERROR, SILENT. + *
+ * Important pre-requisite: set simple logger log level to same level as the SDK_LOG_LEVEL, + * for example via VM options: -Dorg.slf4j.simpleLogger.log.org.hiero=trace + */ + private static final String SDK_LOG_LEVEL = Dotenv.load().get("SDK_LOG_LEVEL", "SILENT"); + + public static void main(String[] args) throws Exception { + System.out.println("Fee Estimate Example Start!"); + + /* + * Step 0: + * Create and configure the SDK Client. + */ + Client client = ClientHelper.forName(HEDERA_NETWORK); + // All generated transactions will be paid by this account and signed by this key. + client.setOperator(OPERATOR_ID, OPERATOR_KEY); + // Attach logger to the SDK Client. + client.setLogger(new Logger(LogLevel.valueOf(SDK_LOG_LEVEL))); + + // Create a recipient account for the example + AccountId recipientId = AccountId.fromString("0.0.3"); + + /* + * Step 1: + * Create and freeze a transfer transaction. + * The transaction must be frozen before it can be estimated. + */ + System.out.println("\n=== Creating Transfer Transaction ==="); + Hbar transferAmount = Hbar.from(1); + + TransferTransaction tx = new TransferTransaction() + .addHbarTransfer(OPERATOR_ID, transferAmount.negated()) + .addHbarTransfer(recipientId, transferAmount) + .setTransactionMemo("Fee estimate example") + .freezeWith(client); + + // Sign the transaction (required for accurate fee estimation) + tx.signWithOperator(client); + + System.out.println("Transaction created: Transfer " + transferAmount + " from " + + OPERATOR_ID + " to " + recipientId); + + /* + * Step 2: + * Estimate fees with STATE mode (default). + * STATE mode considers the current network state (e.g., whether accounts exist, + * token associations, etc.) for more accurate fee estimation. + */ + System.out.println("\n=== Estimating Fees with STATE Mode ==="); + + FeeEstimateResponse stateEstimate = new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tx) + .execute(client); + + System.out.println("Mode: " + stateEstimate.getMode()); + + // Network fee breakdown + System.out.println("\nNetwork Fee:"); + System.out.println(" Multiplier: " + stateEstimate.getNetworkFee().getMultiplier()); + System.out.println(" Subtotal: " + stateEstimate.getNetworkFee().getSubtotal() + " tinycents"); + + // Node fee breakdown + System.out.println("\nNode Fee:"); + System.out.println(" Base: " + stateEstimate.getNodeFee().getBase() + " tinycents"); + long nodeTotal = stateEstimate.getNodeFee().getBase(); + for (FeeExtra extra : stateEstimate.getNodeFee().getExtras()) { + System.out.println(" Extra - " + extra.getName() + ": " + extra.getSubtotal() + " tinycents"); + nodeTotal += extra.getSubtotal(); + } + System.out.println(" Node Total: " + nodeTotal + " tinycents"); + + // Service fee breakdown + System.out.println("\nService Fee:"); + System.out.println(" Base: " + stateEstimate.getServiceFee().getBase() + " tinycents"); + long serviceTotal = stateEstimate.getServiceFee().getBase(); + for (FeeExtra extra : stateEstimate.getServiceFee().getExtras()) { + System.out.println(" Extra - " + extra.getName() + ": " + extra.getSubtotal() + " tinycents"); + serviceTotal += extra.getSubtotal(); + } + System.out.println(" Service Total: " + serviceTotal + " tinycents"); + + // Total fee + System.out.println("\nTotal Estimated Fee: " + stateEstimate.getTotal() + " tinycents"); + System.out.println("Total Estimated Fee: " + Hbar.fromTinybars(stateEstimate.getTotal() / 100)); + + // Display any notes/caveats + if (!stateEstimate.getNotes().isEmpty()) { + System.out.println("\nNotes:"); + for (String note : stateEstimate.getNotes()) { + System.out.println(" - " + note); + } + } + + /* + * Step 3: + * Estimate fees with INTRINSIC mode. + * INTRINSIC mode only considers the transaction's inherent properties + * (size, signatures, keys) and ignores state-dependent factors. + */ + System.out.println("\n=== Estimating Fees with INTRINSIC Mode ==="); + + FeeEstimateResponse intrinsicEstimate = new FeeEstimateQuery() + .setMode(FeeEstimateMode.INTRINSIC) + .setTransaction(tx) + .execute(client); + + System.out.println("Mode: " + intrinsicEstimate.getMode()); + System.out.println("Network Fee Subtotal: " + intrinsicEstimate.getNetworkFee().getSubtotal() + " tinycents"); + System.out.println("Node Fee Base: " + intrinsicEstimate.getNodeFee().getBase() + " tinycents"); + System.out.println("Service Fee Base: " + intrinsicEstimate.getServiceFee().getBase() + " tinycents"); + System.out.println("Total Estimated Fee: " + intrinsicEstimate.getTotal() + " tinycents"); + System.out.println("Total Estimated Fee: " + Hbar.fromTinybars(intrinsicEstimate.getTotal() / 100)); + + /* + * Step 4: + * Compare STATE vs INTRINSIC mode estimates. + */ + System.out.println("\n=== Comparison ==="); + System.out.println("STATE mode total: " + stateEstimate.getTotal() + " tinycents"); + System.out.println("INTRINSIC mode total: " + intrinsicEstimate.getTotal() + " tinycents"); + long difference = Math.abs(stateEstimate.getTotal() - intrinsicEstimate.getTotal()); + System.out.println("Difference: " + difference + " tinycents"); + + /* + * Step 5: + * Demonstrate fee estimation for a token creation transaction. + */ + System.out.println("\n=== Estimating Token Creation Fees ==="); + + TokenCreateTransaction tokenTx = new TokenCreateTransaction() + .setTokenName("Example Token") + .setTokenSymbol("EXT") + .setDecimals(3) + .setInitialSupply(1000000) + .setTreasuryAccountId(OPERATOR_ID) + .setAdminKey(OPERATOR_KEY) + .freezeWith(client) + .signWithOperator(client); + + FeeEstimateResponse tokenEstimate = new FeeEstimateQuery() + .setMode(FeeEstimateMode.STATE) + .setTransaction(tokenTx) + .execute(client); + + System.out.println("Token Creation Estimated Fee: " + tokenEstimate.getTotal() + " tinycents"); + System.out.println("Token Creation Estimated Fee: " + Hbar.fromTinybars(tokenEstimate.getTotal() / 100)); + + /* + * Clean up: + */ + client.close(); + System.out.println("\nExample complete!"); + } +} + diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimate.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimate.java new file mode 100644 index 0000000000..545d7e4b39 --- /dev/null +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimate.java @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.hedera.hashgraph.sdk; + +import com.google.common.base.MoreObjects; +import com.google.protobuf.InvalidProtocolBufferException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * The fee estimate for a fee component (node or service). + *
+ * Includes the base fee and any extras associated with it.
+ */
+public final class FeeEstimate {
+ /**
+ * The base fee price, in tinycents.
+ */
+ private final long base;
+
+ /**
+ * The extra fees that apply for this fee component.
+ */
+ private final List
+ * Determines how the fee estimate is calculated for a transaction.
+ */
+public enum FeeEstimateMode {
+ /**
+ * Default mode: uses latest known state.
+ *
+ * This mode calculates fees based on the current state of the network,
+ * taking into account all state-dependent factors such as current
+ * exchange rates, gas prices, and network congestion.
+ */
+ STATE(0),
+
+ /**
+ * Intrinsic mode: ignores state-dependent factors.
+ *
+ * This mode calculates fees based only on the intrinsic properties of
+ * the transaction itself, ignoring dynamic network conditions. This
+ * provides a baseline estimate that doesn't fluctuate with network state.
+ */
+ INTRINSIC(1);
+
+ final int code;
+
+ FeeEstimateMode(int code) {
+ this.code = code;
+ }
+
+ /**
+ * Convert a protobuf-encoded fee estimate mode value to the corresponding enum.
+ *
+ * @param code the protobuf-encoded value
+ * @return the corresponding FeeEstimateMode
+ * @throws IllegalArgumentException if the code is not recognized
+ */
+ static FeeEstimateMode valueOf(int code) {
+ return switch (code) {
+ case 0 -> STATE;
+ case 1 -> INTRINSIC;
+ default -> throw new IllegalArgumentException("(BUG) unhandled FeeEstimateMode code: " + code);
+ };
+ }
+
+ @Override
+ public String toString() {
+ return switch (this) {
+ case STATE -> "STATE";
+ case INTRINSIC -> "INTRINSIC";
+ };
+ }
+}
diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateQuery.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateQuery.java
new file mode 100644
index 0000000000..26dce735a1
--- /dev/null
+++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateQuery.java
@@ -0,0 +1,307 @@
+// SPDX-License-Identifier: Apache-2.0
+package com.hedera.hashgraph.sdk;
+
+import com.hedera.hashgraph.sdk.proto.mirror.NetworkServiceGrpc;
+import io.grpc.CallOptions;
+import io.grpc.ClientCall;
+import io.grpc.Deadline;
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import io.grpc.stub.ClientCalls;
+import java.time.Duration;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Query the mirror node for fee estimates for a transaction.
+ *
+ * This query allows users, SDKs, and tools to estimate expected fees without
+ * submitting transactions to the network.
+ */
+public class FeeEstimateQuery {
+ private static final Logger LOGGER = LoggerFactory.getLogger(FeeEstimateQuery.class);
+
+ @Nullable
+ private FeeEstimateMode mode = null;
+
+ @Nullable
+ private com.hedera.hashgraph.sdk.proto.Transaction transaction = null;
+
+ private int maxAttempts = 10;
+ private Duration maxBackoff = Duration.ofSeconds(8L);
+
+ /**
+ * Constructor.
+ */
+ public FeeEstimateQuery() {}
+
+ private static boolean shouldRetry(Throwable throwable) {
+ if (throwable instanceof StatusRuntimeException statusRuntimeException) {
+ var code = statusRuntimeException.getStatus().getCode();
+ var description = statusRuntimeException.getStatus().getDescription();
+
+ return (code == io.grpc.Status.Code.UNAVAILABLE)
+ || (code == io.grpc.Status.Code.DEADLINE_EXCEEDED)
+ || (code == io.grpc.Status.Code.RESOURCE_EXHAUSTED)
+ || (code == Status.Code.INTERNAL
+ && description != null
+ && Executable.RST_STREAM.matcher(description).matches());
+ }
+
+ return false;
+ }
+
+ /**
+ * Extract the fee estimate mode.
+ *
+ * @return the fee estimate mode that was set, or null if not set
+ */
+ @Nullable
+ public FeeEstimateMode getMode() {
+ return mode;
+ }
+
+ /**
+ * Set the mode for fee estimation.
+ *
+ * Defaults to {@link FeeEstimateMode#STATE} if not set.
+ *
+ * @param mode the fee estimate mode
+ * @return {@code this}
+ */
+ public FeeEstimateQuery setMode(FeeEstimateMode mode) {
+ Objects.requireNonNull(mode);
+ this.mode = mode;
+ return this;
+ }
+
+ /**
+ * Extract the transaction to estimate fees for.
+ *
+ * @return the transaction that was set, or null if not set
+ */
+ @Nullable
+ public com.hedera.hashgraph.sdk.proto.Transaction getTransaction() {
+ return transaction;
+ }
+
+ /**
+ * Set the transaction to estimate fees for.
+ *
+ * This should be the raw HAPI transaction that will be estimated.
+ *
+ * @param transaction the transaction proto
+ * @return {@code this}
+ */
+ public FeeEstimateQuery setTransaction(com.hedera.hashgraph.sdk.proto.Transaction transaction) {
+ Objects.requireNonNull(transaction);
+ this.transaction = transaction;
+ return this;
+ }
+
+ /**
+ * Set the transaction to estimate fees for from a Transaction object.
+ *
+ * @param transaction the transaction to estimate
+ * @return {@code this}
+ */
+ public
+ * This response provides a breakdown of the network, node, and service fees,
+ * along with the total estimated cost in tinycents.
+ */
+public final class FeeEstimateResponse {
+ /**
+ * The mode that was used to calculate the fees.
+ */
+ private final FeeEstimateMode mode;
+
+ /**
+ * The network fee component which covers the cost of gossip, consensus,
+ * signature verifications, fee payment, and storage.
+ */
+ @Nullable
+ private final NetworkFee networkFee;
+
+ /**
+ * 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.
+ */
+ @Nullable
+ private final FeeEstimate nodeFee;
+
+ /**
+ * The service fee component which covers execution costs, state saved in the
+ * Merkle tree, and additional costs to the blockchain storage.
+ */
+ @Nullable
+ private final FeeEstimate serviceFee;
+
+ /**
+ * An array of strings for any caveats.
+ *
+ * For example: ["Fallback to worst-case due to missing state"]
+ */
+ private final List
+ * Represents additional fees that apply for specific fee components,
+ * such as charges beyond included amounts.
+ */
+public final class FeeExtra {
+ /**
+ * The charged count of items as calculated by max(0, count - included).
+ */
+ private final int charged;
+
+ /**
+ * The actual count of items received.
+ */
+ private final int count;
+
+ /**
+ * The fee price per unit in tinycents.
+ */
+ private final long feePerUnit;
+
+ /**
+ * The count of this "extra" that is included for free.
+ */
+ private final int included;
+
+ /**
+ * The unique name of this extra fee as defined in the fee schedule.
+ */
+ @Nullable
+ private final String name;
+
+ /**
+ * The subtotal in tinycents for this extra fee.
+ *
+ * Calculated by multiplying the charged count by the fee_per_unit.
+ */
+ private final long subtotal;
+
+ /**
+ * Constructor.
+ *
+ * @param charged the charged count of items
+ * @param count the actual count of items
+ * @param feePerUnit the fee price per unit in tinycents
+ * @param included the count included for free
+ * @param name the unique name of this extra fee
+ * @param subtotal the subtotal in tinycents
+ */
+ FeeExtra(int charged, int count, long feePerUnit, int included, @Nullable String name, long subtotal) {
+ this.charged = charged;
+ this.count = count;
+ this.feePerUnit = feePerUnit;
+ this.included = included;
+ this.name = name;
+ this.subtotal = subtotal;
+ }
+
+ /**
+ * Create a FeeExtra from a protobuf.
+ *
+ * @param feeExtra the protobuf
+ * @return the new FeeExtra
+ */
+ static FeeExtra fromProtobuf(com.hedera.hashgraph.sdk.proto.mirror.FeeExtra feeExtra) {
+ return new FeeExtra(
+ feeExtra.getCharged(),
+ feeExtra.getCount(),
+ feeExtra.getFeePerUnit(),
+ feeExtra.getIncluded(),
+ feeExtra.getName().isEmpty() ? null : feeExtra.getName(),
+ feeExtra.getSubtotal());
+ }
+
+ /**
+ * Create a FeeExtra from a byte array.
+ *
+ * @param bytes the byte array
+ * @return the new FeeExtra
+ * @throws InvalidProtocolBufferException when there is an issue with the protobuf
+ */
+ public static FeeExtra fromBytes(byte[] bytes) throws InvalidProtocolBufferException {
+ return fromProtobuf(com.hedera.hashgraph.sdk.proto.mirror.FeeExtra.parseFrom(bytes).toBuilder()
+ .build());
+ }
+
+ /**
+ * Extract the charged count of items.
+ *
+ * @return the charged count of items
+ */
+ public int getCharged() {
+ return charged;
+ }
+
+ /**
+ * Extract the actual count of items.
+ *
+ * @return the actual count of items
+ */
+ public int getCount() {
+ return count;
+ }
+
+ /**
+ * Extract the fee price per unit in tinycents.
+ *
+ * @return the fee price per unit in tinycents
+ */
+ public long getFeePerUnit() {
+ return feePerUnit;
+ }
+
+ /**
+ * Extract the count included for free.
+ *
+ * @return the count included for free
+ */
+ public int getIncluded() {
+ return included;
+ }
+
+ /**
+ * Extract the unique name of this extra fee.
+ *
+ * @return the unique name of this extra fee, or null if not set
+ */
+ @Nullable
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Extract the subtotal in tinycents.
+ *
+ * @return the subtotal in tinycents
+ */
+ public long getSubtotal() {
+ return subtotal;
+ }
+
+ /**
+ * Convert the fee extra to a protobuf.
+ *
+ * @return the protobuf
+ */
+ com.hedera.hashgraph.sdk.proto.mirror.FeeExtra toProtobuf() {
+ var builder = com.hedera.hashgraph.sdk.proto.mirror.FeeExtra.newBuilder()
+ .setCharged(charged)
+ .setCount(count)
+ .setFeePerUnit(feePerUnit)
+ .setIncluded(included)
+ .setSubtotal(subtotal);
+
+ if (name != null) {
+ builder.setName(name);
+ }
+
+ return builder.build();
+ }
+
+ /**
+ * Convert the fee extra to a byte array.
+ *
+ * @return the byte array
+ */
+ public byte[] toBytes() {
+ return toProtobuf().toByteArray();
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("charged", charged)
+ .add("count", count)
+ .add("feePerUnit", feePerUnit)
+ .add("included", included)
+ .add("name", name)
+ .add("subtotal", subtotal)
+ .toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof FeeExtra that)) {
+ return false;
+ }
+ return charged == that.charged
+ && count == that.count
+ && feePerUnit == that.feePerUnit
+ && included == that.included
+ && subtotal == that.subtotal
+ && Objects.equals(name, that.name);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(charged, count, feePerUnit, included, name, subtotal);
+ }
+}
diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/NetworkFee.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/NetworkFee.java
new file mode 100644
index 0000000000..db4b5f5309
--- /dev/null
+++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/NetworkFee.java
@@ -0,0 +1,102 @@
+// SPDX-License-Identifier: Apache-2.0
+package com.hedera.hashgraph.sdk;
+
+import com.google.common.base.MoreObjects;
+import com.google.protobuf.InvalidProtocolBufferException;
+
+/**
+ * The network fee component which covers the cost of gossip, consensus,
+ * signature verifications, fee payment, and storage.
+ */
+public final class NetworkFee {
+ /**
+ * Multiplied by the node fee to determine the total network fee.
+ */
+ private final int multiplier;
+
+ /**
+ * The subtotal in tinycents for the network fee component which is calculated by
+ * multiplying the node subtotal by the network multiplier.
+ */
+ private final long subtotal;
+
+ /**
+ * Constructor.
+ *
+ * @param multiplier the network fee multiplier
+ * @param subtotal the network fee subtotal in tinycents
+ */
+ NetworkFee(int multiplier, long subtotal) {
+ this.multiplier = multiplier;
+ this.subtotal = subtotal;
+ }
+
+ /**
+ * Create a NetworkFee from a protobuf.
+ *
+ * @param networkFee the protobuf
+ * @return the new NetworkFee
+ */
+ static NetworkFee fromProtobuf(com.hedera.hashgraph.sdk.proto.mirror.NetworkFee networkFee) {
+ return new NetworkFee(networkFee.getMultiplier(), networkFee.getSubtotal());
+ }
+
+ /**
+ * Create a NetworkFee from a byte array.
+ *
+ * @param bytes the byte array
+ * @return the new NetworkFee
+ * @throws InvalidProtocolBufferException when there is an issue with the protobuf
+ */
+ public static NetworkFee fromBytes(byte[] bytes) throws InvalidProtocolBufferException {
+ return fromProtobuf(com.hedera.hashgraph.sdk.proto.mirror.NetworkFee.parseFrom(bytes).toBuilder()
+ .build());
+ }
+
+ /**
+ * Extract the network fee multiplier.
+ *
+ * @return the network fee multiplier
+ */
+ public int getMultiplier() {
+ return multiplier;
+ }
+
+ /**
+ * Extract the network fee subtotal in tinycents.
+ *
+ * @return the network fee subtotal in tinycents
+ */
+ public long getSubtotal() {
+ return subtotal;
+ }
+
+ /**
+ * Convert the network fee to a protobuf.
+ *
+ * @return the protobuf
+ */
+ com.hedera.hashgraph.sdk.proto.mirror.NetworkFee toProtobuf() {
+ return com.hedera.hashgraph.sdk.proto.mirror.NetworkFee.newBuilder()
+ .setMultiplier(multiplier)
+ .setSubtotal(subtotal)
+ .build();
+ }
+
+ /**
+ * Convert the network fee to a byte array.
+ *
+ * @return the byte array
+ */
+ public byte[] toBytes() {
+ return toProtobuf().toByteArray();
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("multiplier", multiplier)
+ .add("subtotal", subtotal)
+ .toString();
+ }
+}
diff --git a/sdk/src/main/proto/mirror/fee.proto b/sdk/src/main/proto/mirror/fee.proto
new file mode 100644
index 0000000000..0816699c22
--- /dev/null
+++ b/sdk/src/main/proto/mirror/fee.proto
@@ -0,0 +1,151 @@
+// SPDX-License-Identifier: Apache-2.0
+
+syntax = "proto3";
+
+option java_package = "com.hedera.hashgraph.sdk.proto.mirror";
+option java_multiple_files = true;
+
+import "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/sdk/src/main/proto/mirror/mirror_network_service.proto b/sdk/src/main/proto/mirror/mirror_network_service.proto
index 460da29f30..65c0de39af 100644
--- a/sdk/src/main/proto/mirror/mirror_network_service.proto
+++ b/sdk/src/main/proto/mirror/mirror_network_service.proto
@@ -27,6 +27,7 @@ option java_package = "com.hedera.hashgraph.sdk.proto.mirror";
import "basic_types.proto";
import "timestamp.proto";
+import "mirror/fee.proto";
/**
* Request object to query an address book for its list of nodes
@@ -47,6 +48,10 @@ message AddressBookQuery {
* Provides cross network APIs like address book queries
*/
service NetworkService {
+ /**
+ * Query to estimate the fees when submitting a transaction to the network.
+ */
+ rpc getFeeEstimate(FeeEstimateQuery) returns (FeeEstimateResponse);
/*
* Query for an address book and return its nodes. The nodes are returned in ascending order by node ID. The
* response is not guaranteed to be a byte-for-byte equivalent to the NodeAddress in the Hedera file on
diff --git a/sdk/src/test/java/com/hedera/hashgraph/sdk/FeeEstimateQueryMockTest.java b/sdk/src/test/java/com/hedera/hashgraph/sdk/FeeEstimateQueryMockTest.java
new file mode 100644
index 0000000000..2ee203c716
--- /dev/null
+++ b/sdk/src/test/java/com/hedera/hashgraph/sdk/FeeEstimateQueryMockTest.java
@@ -0,0 +1,187 @@
+// SPDX-License-Identifier: Apache-2.0
+package com.hedera.hashgraph.sdk;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.google.protobuf.ByteString;
+import com.hedera.hashgraph.sdk.proto.mirror.NetworkServiceGrpc;
+import io.grpc.Server;
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import io.grpc.inprocess.InProcessServerBuilder;
+import io.grpc.stub.StreamObserver;
+import java.time.Duration;
+import java.util.ArrayDeque;
+import java.util.Collections;
+import java.util.Queue;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class FeeEstimateQueryMockTest {
+
+ private static final com.hedera.hashgraph.sdk.proto.Transaction DUMMY_TRANSACTION =
+ com.hedera.hashgraph.sdk.proto.Transaction.newBuilder()
+ .setSignedTransactionBytes(ByteString.copyFromUtf8("dummy"))
+ .build();
+
+ private Client client;
+ private FeeEstimateServiceStub feeEstimateServiceStub;
+ private Server server;
+ private FeeEstimateQuery query;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ client = Client.forNetwork(Collections.emptyMap());
+ client.setRequestTimeout(Duration.ofSeconds(10));
+ // FIX: Use unique in-process server name for each test run
+ String serverName = "test-" + System.nanoTime();
+
+ client.setMirrorNetwork(Collections.singletonList("in-process:" + serverName));
+
+ feeEstimateServiceStub = new FeeEstimateServiceStub();
+ server = InProcessServerBuilder.forName(serverName) // FIX: unique name here
+ .addService(feeEstimateServiceStub)
+ .directExecutor()
+ .build()
+ .start();
+
+ query = new FeeEstimateQuery();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ // Verify the stub received and processed all requests
+ feeEstimateServiceStub.verify();
+
+ // FIX: ensure proper cleanup between tests
+ if (server != null) {
+ server.shutdownNow(); // FIX: force shutdown to avoid lingering registrations
+ server.awaitTermination(1, TimeUnit.SECONDS);
+ }
+ if (client != null) {
+ client.close();
+ }
+ }
+
+ @Test
+ @DisplayName(
+ "Given a FeeEstimateQuery is executed when the Mirror service is unavailable, when the query is executed, then it retries according to the existing query retry policy for UNAVAILABLE errors")
+ void retriesOnUnavailableErrors() {
+ query.setTransaction(DUMMY_TRANSACTION).setMaxAttempts(3).setMaxBackoff(Duration.ofMillis(500));
+
+ var expectedRequest = com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateQuery.newBuilder()
+ .setModeValue(FeeEstimateMode.STATE.code)
+ .setTransaction(DUMMY_TRANSACTION)
+ .build();
+
+ feeEstimateServiceStub.enqueueError(
+ expectedRequest, Status.UNAVAILABLE.withDescription("transient").asRuntimeException());
+ feeEstimateServiceStub.enqueue(expectedRequest, newSuccessResponse(FeeEstimateMode.STATE, 2, 6, 8));
+
+ var response = query.execute(client);
+
+ assertThat(response.getMode()).isEqualTo(FeeEstimateMode.STATE);
+ assertThat(response.getTotal()).isEqualTo(26);
+ assertThat(feeEstimateServiceStub.requestCount()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName(
+ "Given a FeeEstimateQuery times out, when the query is executed, then it retries according to the existing query retry policy for DEADLINE_EXCEEDED errors")
+ void retriesOnDeadlineExceededErrors() {
+ query.setTransaction(DUMMY_TRANSACTION).setMaxAttempts(3).setMaxBackoff(Duration.ofMillis(500));
+
+ var expectedRequest = com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateQuery.newBuilder()
+ .setModeValue(FeeEstimateMode.STATE.code)
+ .setTransaction(DUMMY_TRANSACTION)
+ .build();
+
+ feeEstimateServiceStub.enqueueError(
+ expectedRequest,
+ Status.DEADLINE_EXCEEDED.withDescription("timeout").asRuntimeException());
+ feeEstimateServiceStub.enqueue(expectedRequest, newSuccessResponse(FeeEstimateMode.STATE, 4, 8, 20));
+
+ var response = query.execute(client);
+
+ assertThat(response.getMode()).isEqualTo(FeeEstimateMode.STATE);
+ assertThat(response.getTotal()).isEqualTo(60);
+ assertThat(feeEstimateServiceStub.requestCount()).isEqualTo(2);
+ }
+
+ private static com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateResponse newSuccessResponse(
+ FeeEstimateMode mode, int networkMultiplier, long nodeBase, long serviceBase) {
+ long networkSubtotal = nodeBase * networkMultiplier;
+ long total = networkSubtotal + nodeBase + serviceBase;
+ return com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateResponse.newBuilder()
+ .setModeValue(mode.code)
+ .setNetwork(com.hedera.hashgraph.sdk.proto.mirror.NetworkFee.newBuilder()
+ .setMultiplier(networkMultiplier)
+ .setSubtotal(networkSubtotal)
+ .build())
+ .setNode(com.hedera.hashgraph.sdk.proto.mirror.FeeEstimate.newBuilder()
+ .setBase(nodeBase)
+ .build())
+ .setService(com.hedera.hashgraph.sdk.proto.mirror.FeeEstimate.newBuilder()
+ .setBase(serviceBase)
+ .build())
+ .setTotal(total)
+ .build();
+ }
+
+ private static class FeeEstimateServiceStub extends NetworkServiceGrpc.NetworkServiceImplBase {
+ private final Queue