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 extras; + + /** + * Constructor. + * + * @param base the base fee price in tinycents + * @param extras the list of extra fees + */ + FeeEstimate(long base, List extras) { + this.base = base; + this.extras = Collections.unmodifiableList(new ArrayList<>(extras)); + } + + /** + * Create a FeeEstimate from a protobuf. + * + * @param feeEstimate the protobuf + * @return the new FeeEstimate + */ + static FeeEstimate fromProtobuf(com.hedera.hashgraph.sdk.proto.mirror.FeeEstimate feeEstimate) { + List extras = new ArrayList<>(feeEstimate.getExtrasCount()); + for (var extraProto : feeEstimate.getExtrasList()) { + extras.add(FeeExtra.fromProtobuf(extraProto)); + } + return new FeeEstimate(feeEstimate.getBase(), extras); + } + + /** + * Create a FeeEstimate from a byte array. + * + * @param bytes the byte array + * @return the new FeeEstimate + * @throws InvalidProtocolBufferException when there is an issue with the protobuf + */ + public static FeeEstimate fromBytes(byte[] bytes) throws InvalidProtocolBufferException { + return fromProtobuf(com.hedera.hashgraph.sdk.proto.mirror.FeeEstimate.parseFrom(bytes).toBuilder() + .build()); + } + + /** + * Extract the base fee price in tinycents. + * + * @return the base fee price in tinycents + */ + public long getBase() { + return base; + } + + /** + * Extract the list of extra fees. + * + * @return an unmodifiable list of extra fees + */ + public List getExtras() { + return extras; + } + + /** + * Convert the fee estimate to a protobuf. + * + * @return the protobuf + */ + com.hedera.hashgraph.sdk.proto.mirror.FeeEstimate toProtobuf() { + var builder = + com.hedera.hashgraph.sdk.proto.mirror.FeeEstimate.newBuilder().setBase(base); + + for (var extra : extras) { + builder.addExtras(extra.toProtobuf()); + } + + return builder.build(); + } + + /** + * Convert the fee estimate to a byte array. + * + * @return the byte array + */ + public byte[] toBytes() { + return toProtobuf().toByteArray(); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("base", base) + .add("extras", extras) + .toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof FeeEstimate that)) { + return false; + } + return base == that.base && Objects.equals(extras, that.extras); + } + + @Override + public int hashCode() { + return Objects.hash(base, extras); + } +} diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateMode.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateMode.java new file mode 100644 index 0000000000..262ee0802d --- /dev/null +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateMode.java @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.hedera.hashgraph.sdk; + +/** + * Enum for the fee estimate mode. + *

+ * 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 > FeeEstimateQuery setTransaction( + com.hedera.hashgraph.sdk.Transaction transaction) { + Objects.requireNonNull(transaction); + this.transaction = transaction.makeRequest(); + return this; + } + + /** + * Extract the maximum number of attempts. + * + * @return the maximum number of attempts + */ + public int getMaxAttempts() { + return maxAttempts; + } + + /** + * Set the maximum number of attempts for the query. + * + * @param maxAttempts the maximum number of attempts + * @return {@code this} + */ + public FeeEstimateQuery setMaxAttempts(int maxAttempts) { + this.maxAttempts = maxAttempts; + return this; + } + + /** + * Extract the maximum backoff duration. + * + * @return the maximum backoff duration + */ + public Duration getMaxBackoff() { + return maxBackoff; + } + + /** + * Set the maximum backoff duration for retry attempts. + * + * @param maxBackoff the maximum backoff duration + * @return {@code this} + */ + public FeeEstimateQuery setMaxBackoff(Duration maxBackoff) { + Objects.requireNonNull(maxBackoff); + if (maxBackoff.toMillis() < 500L) { + throw new IllegalArgumentException("maxBackoff must be at least 500 ms"); + } + this.maxBackoff = maxBackoff; + return this; + } + + /** + * Execute the query with preset timeout. + * + * @param client the client object + * @return the fee estimate response + */ + public FeeEstimateResponse execute(Client client) { + return execute(client, client.getRequestTimeout()); + } + + /** + * Execute the query with user supplied timeout. + * + * @param client the client object + * @param timeout the user supplied timeout + * @return the fee estimate response + */ + public FeeEstimateResponse execute(Client client, Duration timeout) { + var deadline = Deadline.after(timeout.toMillis(), TimeUnit.MILLISECONDS); + for (int attempt = 1; true; attempt++) { + try { + var responseProto = ClientCalls.blockingUnaryCall(buildCall(client, deadline), buildQuery()); + return FeeEstimateResponse.fromProtobuf(responseProto); + } catch (Throwable error) { + if (!shouldRetry(error) || attempt >= maxAttempts) { + LOGGER.error("Error attempting to get fee estimate", error); + throw error; + } + warnAndDelay(attempt, error); + } + } + } + + /** + * Execute the query with preset timeout asynchronously. + * + * @param client the client object + * @return the fee estimate response + */ + public CompletableFuture executeAsync(Client client) { + return executeAsync(client, client.getRequestTimeout()); + } + + /** + * Execute the query with user supplied timeout asynchronously. + * + * @param client the client object + * @param timeout the user supplied timeout + * @return the fee estimate response + */ + public CompletableFuture executeAsync(Client client, Duration timeout) { + var deadline = Deadline.after(timeout.toMillis(), TimeUnit.MILLISECONDS); + CompletableFuture returnFuture = new CompletableFuture<>(); + executeAsync(client, deadline, returnFuture, 1); + return returnFuture; + } + + /** + * Execute the query asynchronously (internal implementation). + * + * @param client the client object + * @param deadline the deadline for the call + * @param returnFuture the future to complete with the result + * @param attempt the current attempt number + */ + void executeAsync( + Client client, Deadline deadline, CompletableFuture returnFuture, int attempt) { + ClientCalls.asyncUnaryCall( + buildCall(client, deadline), + buildQuery(), + new io.grpc.stub.StreamObserver() { + @Override + public void onNext(com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateResponse response) { + returnFuture.complete(FeeEstimateResponse.fromProtobuf(response)); + } + + @Override + public void onError(Throwable error) { + if (attempt >= maxAttempts || !shouldRetry(error)) { + LOGGER.error("Error attempting to get fee estimate", error); + returnFuture.completeExceptionally(error); + return; + } + warnAndDelay(attempt, error); + executeAsync(client, deadline, returnFuture, attempt + 1); + } + + @Override + public void onCompleted() { + // Response already handled in onNext + } + }); + } + + /** + * Build the FeeEstimateQuery protobuf message. + * + * @return the protobuf query + */ + com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateQuery buildQuery() { + var builder = com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateQuery.newBuilder(); + + if (mode != null) { + builder.setModeValue(mode.code); + } else { + // Default to STATE mode + builder.setModeValue(FeeEstimateMode.STATE.code); + } + + if (transaction != null) { + builder.setTransaction(transaction); + } + + return builder.build(); + } + + private ClientCall< + com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateQuery, + com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateResponse> + buildCall(Client client, Deadline deadline) { + try { + return client.mirrorNetwork + .getNextMirrorNode() + .getChannel() + .newCall(NetworkServiceGrpc.getGetFeeEstimateMethod(), CallOptions.DEFAULT.withDeadline(deadline)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + private void warnAndDelay(int attempt, Throwable error) { + var delay = Math.min(500 * (long) Math.pow(2, attempt), maxBackoff.toMillis()); + LOGGER.warn( + "Error fetching fee estimate during attempt #{}. Waiting {} ms before next attempt: {}", + attempt, + delay, + error.getMessage()); + + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateResponse.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateResponse.java new file mode 100644 index 0000000000..e2388d9781 --- /dev/null +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateResponse.java @@ -0,0 +1,235 @@ +// 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; +import javax.annotation.Nullable; + +/** + * The response containing the estimated transaction fees. + *

+ * 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 notes; + + /** + * The sum of the network, node, and service subtotals in tinycents. + */ + private final long total; + + /** + * Constructor. + * + * @param mode the fee estimate mode used + * @param networkFee the network fee component + * @param nodeFee the node fee estimate + * @param notes the list of notes/caveats + * @param serviceFee the service fee estimate + * @param total the total fee in tinycents + */ + FeeEstimateResponse( + FeeEstimateMode mode, + @Nullable NetworkFee networkFee, + @Nullable FeeEstimate nodeFee, + List notes, + @Nullable FeeEstimate serviceFee, + long total) { + this.mode = mode; + this.networkFee = networkFee; + this.nodeFee = nodeFee; + this.notes = Collections.unmodifiableList(new ArrayList<>(notes)); + this.serviceFee = serviceFee; + this.total = total; + } + + /** + * Create a FeeEstimateResponse from a protobuf. + * + * @param response the protobuf + * @return the new FeeEstimateResponse + */ + static FeeEstimateResponse fromProtobuf(com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateResponse response) { + var mode = FeeEstimateMode.valueOf(response.getModeValue()); + var network = response.hasNetwork() ? NetworkFee.fromProtobuf(response.getNetwork()) : null; + var node = response.hasNode() ? FeeEstimate.fromProtobuf(response.getNode()) : null; + var notes = new ArrayList<>(response.getNotesList()); + var service = response.hasService() ? FeeEstimate.fromProtobuf(response.getService()) : null; + var total = response.getTotal(); + + return new FeeEstimateResponse(mode, network, node, notes, service, total); + } + + /** + * Create a FeeEstimateResponse from a byte array. + * + * @param bytes the byte array + * @return the new FeeEstimateResponse + * @throws InvalidProtocolBufferException when there is an issue with the protobuf + */ + public static FeeEstimateResponse fromBytes(byte[] bytes) throws InvalidProtocolBufferException { + return fromProtobuf(com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateResponse.parseFrom(bytes).toBuilder() + .build()); + } + + /** + * Extract the fee estimate mode used. + * + * @return the fee estimate mode + */ + public FeeEstimateMode getMode() { + return mode; + } + + /** + * Extract the network fee component. + * + * @return the network fee component, or null if not set + */ + @Nullable + public NetworkFee getNetworkFee() { + return networkFee; + } + + /** + * Extract the node fee estimate. + * + * @return the node fee estimate, or null if not set + */ + @Nullable + public FeeEstimate getNodeFee() { + return nodeFee; + } + + /** + * Extract the list of notes/caveats. + * + * @return an unmodifiable list of notes + */ + public List getNotes() { + return notes; + } + + /** + * Extract the service fee estimate. + * + * @return the service fee estimate, or null if not set + */ + @Nullable + public FeeEstimate getServiceFee() { + return serviceFee; + } + + /** + * Extract the total fee in tinycents. + * + * @return the total fee in tinycents + */ + public long getTotal() { + return total; + } + + /** + * Convert the fee estimate response to a protobuf. + * + * @return the protobuf + */ + com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateResponse toProtobuf() { + var builder = com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateResponse.newBuilder() + .setModeValue(mode.code) + .setTotal(total) + .addAllNotes(notes); + + if (networkFee != null) { + builder.setNetwork(networkFee.toProtobuf()); + } + if (nodeFee != null) { + builder.setNode(nodeFee.toProtobuf()); + } + if (serviceFee != null) { + builder.setService(serviceFee.toProtobuf()); + } + + return builder.build(); + } + + /** + * Convert the fee estimate response to a byte array. + * + * @return the byte array + */ + public byte[] toBytes() { + return toProtobuf().toByteArray(); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("mode", mode) + .add("network", networkFee) + .add("node", nodeFee) + .add("notes", notes) + .add("service", serviceFee) + .add("total", total) + .toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof FeeEstimateResponse that)) { + return false; + } + return total == that.total + && mode == that.mode + && Objects.equals(networkFee, that.networkFee) + && Objects.equals(nodeFee, that.nodeFee) + && Objects.equals(notes, that.notes) + && Objects.equals(serviceFee, that.serviceFee); + } + + @Override + public int hashCode() { + return Objects.hash(mode, networkFee, nodeFee, notes, serviceFee, total); + } +} diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeExtra.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeExtra.java new file mode 100644 index 0000000000..b3e8814723 --- /dev/null +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeExtra.java @@ -0,0 +1,212 @@ +// 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.Objects; +import javax.annotation.Nullable; + +/** + * The extra fee charged for the transaction. + *

+ * 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 expectedRequests = + new ArrayDeque<>(); + private final Queue responses = new ArrayDeque<>(); + private int observedRequests = 0; + + void enqueue( + com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateQuery request, + com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateResponse response) { + expectedRequests.add(request); + responses.add(response); + } + + void enqueueError( + com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateQuery request, StatusRuntimeException error) { + expectedRequests.add(request); + responses.add(error); + } + + int requestCount() { + return observedRequests; + } + + void verify() { + assertThat(expectedRequests).isEmpty(); + assertThat(responses).isEmpty(); + } + + @Override + public void getFeeEstimate( + com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateQuery request, + StreamObserver responseObserver) { + observedRequests++; + var expected = expectedRequests.poll(); + assertThat(expected) + .as("expected request to be queued before invoking getFeeEstimate") + .isNotNull(); + assertThat(request).isEqualTo(expected); + + var response = responses.poll(); + assertThat(response) + .as("response or error should be queued before invoking getFeeEstimate") + .isNotNull(); + + if (response instanceof StatusRuntimeException error) { + responseObserver.onError(error); + return; + } + + responseObserver.onNext((com.hedera.hashgraph.sdk.proto.mirror.FeeEstimateResponse) response); + responseObserver.onCompleted(); + } + } +} diff --git a/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/FeeEstimateQueryIntegrationTest.java b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/FeeEstimateQueryIntegrationTest.java new file mode 100644 index 0000000000..efb9365690 --- /dev/null +++ b/sdk/src/testIntegration/java/com/hedera/hashgraph/sdk/test/integration/FeeEstimateQueryIntegrationTest.java @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: Apache-2.0 +package com.hedera.hashgraph.sdk.test.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.google.protobuf.ByteString; +import com.hedera.hashgraph.sdk.*; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class FeeEstimateQueryIntegrationTest { + + private static final long MIRROR_SYNC_DELAY_MILLIS = TimeUnit.SECONDS.toMillis(2); + + @Test + @DisplayName("Given a TokenCreateTransaction, when fee estimate is requested, " + + "then response includes service fees for token creation and network fees") + void tokenCreateTransactionFeeEstimate() throws Throwable { + try (var testEnv = new IntegrationTestEnv(1)) { + + // Given: A TokenCreateTransaction is created + var transaction = new TokenCreateTransaction() + .setTokenName("Test Token") + .setTokenSymbol("TEST") + .setDecimals(3) + .setInitialSupply(1000000) + .setTreasuryAccountId(testEnv.operatorId) + .setAdminKey(testEnv.operatorKey) + .freezeWith(testEnv.client) + .signWithOperator(testEnv.client); + + waitForMirrorNodeSync(); + + // When: A fee estimate is requested + FeeEstimateResponse response = new FeeEstimateQuery() + .setTransaction(transaction) + .setMode(FeeEstimateMode.STATE) + .execute(testEnv.client); + + // Then: The response includes appropriate fees + assertFeeComponentsPresent(response); + assertThat(response.getMode()).isEqualTo(FeeEstimateMode.STATE); + assertComponentTotalsConsistent(response); + } + } + + @Test + @DisplayName( + "Given a TransferTransaction, when fee estimate is requested in STATE mode, then all components are returned") + void transferTransactionStateModeFeeEstimate() throws Throwable { + try (var testEnv = new IntegrationTestEnv(1)) { + var transaction = new TransferTransaction() + .addHbarTransfer(testEnv.operatorId, Hbar.fromTinybars(-1)) + .addHbarTransfer(AccountId.fromString("0.0.3"), Hbar.fromTinybars(1)) + .freezeWith(testEnv.client) + .signWithOperator(testEnv.client); + + waitForMirrorNodeSync(); + + var response = new FeeEstimateQuery() + .setTransaction(transaction) + .setMode(FeeEstimateMode.STATE) + .execute(testEnv.client); + + assertFeeComponentsPresent(response); + assertThat(response.getMode()).isEqualTo(FeeEstimateMode.STATE); + assertComponentTotalsConsistent(response); + } + } + + @Test + @Disabled + @DisplayName( + "Given a TransferTransaction, when fee estimate is requested in INTRINSIC mode, then components are returned without state dependencies") + void transferTransactionIntrinsicModeFeeEstimate() throws Throwable { + try (var testEnv = new IntegrationTestEnv(1)) { + var transaction = new TransferTransaction() + .addHbarTransfer(testEnv.operatorId, Hbar.fromTinybars(-1)) + .addHbarTransfer(AccountId.fromString("0.0.3"), Hbar.fromTinybars(1)) + .freezeWith(testEnv.client); + + waitForMirrorNodeSync(); + + var response = new FeeEstimateQuery() + .setTransaction(transaction) + .setMode(FeeEstimateMode.INTRINSIC) + .execute(testEnv.client); + + assertFeeComponentsPresent(response); + assertThat(response.getMode()).isEqualTo(FeeEstimateMode.INTRINSIC); + assertComponentTotalsConsistent(response); + } + } + + @Test + @DisplayName( + "Given a TransferTransaction without explicit mode, when fee estimate is requested, then STATE mode is used by default") + void transferTransactionDefaultModeIsState() throws Throwable { + try (var testEnv = new IntegrationTestEnv(1)) { + var transaction = new TransferTransaction() + .addHbarTransfer(testEnv.operatorId, Hbar.fromTinybars(-1)) + .addHbarTransfer(AccountId.fromString("0.0.3"), Hbar.fromTinybars(1)) + .freezeWith(testEnv.client) + .signWithOperator(testEnv.client); + + waitForMirrorNodeSync(); + + var response = new FeeEstimateQuery().setTransaction(transaction).execute(testEnv.client); + + assertFeeComponentsPresent(response); + assertThat(response.getMode()).isEqualTo(FeeEstimateMode.STATE); + assertComponentTotalsConsistent(response); + } + } + + @Test + @DisplayName("Given a TokenMintTransaction, when fee estimate is requested, then extras are returned for minting") + void tokenMintTransactionFeeEstimate() throws Throwable { + try (var testEnv = new IntegrationTestEnv(1)) { + var transaction = new TokenMintTransaction() + .setTokenId(TokenId.fromString("0.0.1234")) + .setAmount(10) + .freezeWith(testEnv.client); + + waitForMirrorNodeSync(); + + var response = new FeeEstimateQuery() + .setTransaction(transaction) + .setMode(FeeEstimateMode.INTRINSIC) + .execute(testEnv.client); + + assertFeeComponentsPresent(response); + assertThat(response.getNodeFee().getExtras()).isNotNull(); + assertComponentTotalsConsistent(response); + } + } + + @Test + @DisplayName("Given a TopicCreateTransaction, when fee estimate is requested, then service fees are included") + void topicCreateTransactionFeeEstimate() throws Throwable { + try (var testEnv = new IntegrationTestEnv(1)) { + var transaction = new TopicCreateTransaction() + .setTopicMemo("integration test topic") + .freezeWith(testEnv.client) + .signWithOperator(testEnv.client); + + waitForMirrorNodeSync(); + + var response = new FeeEstimateQuery() + .setTransaction(transaction) + .setMode(FeeEstimateMode.STATE) + .execute(testEnv.client); + + assertFeeComponentsPresent(response); + assertComponentTotalsConsistent(response); + } + } + + @Test + @DisplayName("Given a ContractCreateTransaction, when fee estimate is requested, then execution fees are returned") + void contractCreateTransactionFeeEstimate() throws Throwable { + try (var testEnv = new IntegrationTestEnv(1)) { + var transaction = new ContractCreateTransaction() + .setBytecode(new byte[] {1, 2, 3}) + .setGas(1000) + .setAdminKey(testEnv.operatorKey) + .freezeWith(testEnv.client) + .signWithOperator(testEnv.client); + + waitForMirrorNodeSync(); + + var response = new FeeEstimateQuery() + .setTransaction(transaction) + .setMode(FeeEstimateMode.STATE) + .execute(testEnv.client); + + assertFeeComponentsPresent(response); + assertComponentTotalsConsistent(response); + } + } + + @Test + @DisplayName("Given a FileCreateTransaction, when fee estimate is requested, then storage fees are included") + void fileCreateTransactionFeeEstimate() throws Throwable { + try (var testEnv = new IntegrationTestEnv(1)) { + var transaction = new FileCreateTransaction() + .setKeys(testEnv.operatorKey) + .setContents("integration test file") + .freezeWith(testEnv.client) + .signWithOperator(testEnv.client); + + waitForMirrorNodeSync(); + + var response = new FeeEstimateQuery() + .setTransaction(transaction) + .setMode(FeeEstimateMode.STATE) + .execute(testEnv.client); + + assertFeeComponentsPresent(response); + assertComponentTotalsConsistent(response); + } + } + + @Test + @Disabled + @DisplayName( + "Given a FileAppendTransaction spanning multiple chunks, when fee estimate is requested, then aggregated totals are returned") + void fileAppendTransactionFeeEstimateAggregatesChunks() throws Throwable { + try (var testEnv = new IntegrationTestEnv(1)) { + var transaction = new FileAppendTransaction() + .setFileId(FileId.fromString("0.0.1234")) + .setContents(new byte[5000]) + .freezeWith(testEnv.client); + + waitForMirrorNodeSync(); + + var response = new FeeEstimateQuery() + .setTransaction(transaction) + .setMode(FeeEstimateMode.INTRINSIC) + .execute(testEnv.client); + + assertFeeComponentsPresent(response); + assertComponentTotalsConsistent(response); + } + } + + @Test + @DisplayName( + "Given a TopicMessageSubmitTransaction smaller than a chunk, when fee estimate is requested, then a single chunk estimate is returned") + void topicMessageSubmitSingleChunkFeeEstimate() throws Throwable { + try (var testEnv = new IntegrationTestEnv(1)) { + var transaction = new TopicMessageSubmitTransaction() + .setTopicId(TopicId.fromString("0.0.1234")) + .setMessage(new byte[128]) + .freezeWith(testEnv.client); + + waitForMirrorNodeSync(); + + var response = new FeeEstimateQuery() + .setTransaction(transaction) + .setMode(FeeEstimateMode.INTRINSIC) + .execute(testEnv.client); + + assertFeeComponentsPresent(response); + assertComponentTotalsConsistent(response); + } + } + + @Test + @Disabled + @DisplayName( + "Given a TopicMessageSubmitTransaction larger than a chunk, when fee estimate is requested, then multi-chunk totals are aggregated") + void topicMessageSubmitMultipleChunkFeeEstimate() throws Throwable { + try (var testEnv = new IntegrationTestEnv(1)) { + var transaction = new TopicMessageSubmitTransaction() + .setTopicId(TopicId.fromString("0.0.1234")) + .setMessage(new byte[5000]) + .freezeWith(testEnv.client); + + waitForMirrorNodeSync(); + + var response = new FeeEstimateQuery() + .setTransaction(transaction) + .setMode(FeeEstimateMode.INTRINSIC) + .execute(testEnv.client); + + assertFeeComponentsPresent(response); + assertComponentTotalsConsistent(response); + } + } + + @Test + @DisplayName( + "Given a FeeEstimateQuery with a malformed transaction, when the query is executed, then it returns an INVALID_ARGUMENT error and does not retry") + void malformedTransactionReturnsInvalidArgumentError() throws Throwable { + try (var testEnv = new IntegrationTestEnv(1)) { + + // Given: A malformed transaction payload (invalid signed bytes) + ByteString invalidBytes = ByteString.copyFrom(new byte[] {0x00, 0x01, 0x02, 0x03}); + var malformedTransaction = com.hedera.hashgraph.sdk.proto.Transaction.newBuilder() + .setSignedTransactionBytes(invalidBytes) + .build(); + + waitForMirrorNodeSync(); + + // When/Then: Executing the fee estimate query should throw INVALID_ARGUMENT + assertThatThrownBy(() -> new FeeEstimateQuery() + .setTransaction(malformedTransaction) + .setMode(FeeEstimateMode.STATE) + .execute(testEnv.client)) + .isInstanceOf(StatusRuntimeException.class) + .extracting(ex -> ((StatusRuntimeException) ex).getStatus().getCode()) + .isEqualTo(Status.Code.INVALID_ARGUMENT); + } + } + + private static void waitForMirrorNodeSync() throws InterruptedException { + Thread.sleep(MIRROR_SYNC_DELAY_MILLIS); + } + + private static long subtotal(FeeEstimate estimate) { + return estimate.getBase() + + estimate.getExtras().stream().mapToLong(FeeExtra::getSubtotal).sum(); + } + + private static void assertFeeComponentsPresent(FeeEstimateResponse response) { + // TODO adjust when NetworkService.getFeeEstimate has actual implementation + assertThat(response).isNotNull(); + assertThat(response.getNetworkFee()).isNotNull(); + assertThat(response.getNodeFee()).isNotNull(); + assertThat(response.getServiceFee()).isNotNull(); + assertThat(response.getNotes()).isNotNull(); + } + + private static void assertComponentTotalsConsistent(FeeEstimateResponse response) { + // TODO adjust when NetworkService.getFeeEstimate has actual implementation + var network = response.getNetworkFee(); + var node = response.getNodeFee(); + var service = response.getServiceFee(); + + var nodeSubtotal = subtotal(node); + var serviceSubtotal = subtotal(service); + + assertThat(network.getSubtotal()).isEqualTo(nodeSubtotal * network.getMultiplier()); + assertThat(response.getTotal()).isEqualTo(network.getSubtotal() + nodeSubtotal + serviceSubtotal); + } +} diff --git a/sdk/src/testIntegration/java/module-info.java b/sdk/src/testIntegration/java/module-info.java index 921a0eb751..d4f45c82e7 100644 --- a/sdk/src/testIntegration/java/module-info.java +++ b/sdk/src/testIntegration/java/module-info.java @@ -2,6 +2,8 @@ module com.hedera.hashgraph.sdk.test.integration { requires com.hedera.hashgraph.sdk; requires com.esaulpaugh.headlong; + requires io.grpc.inprocess; + requires io.grpc; requires org.assertj.core; requires org.bouncycastle.provider; requires org.junit.jupiter.api;