diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e4cc35449..fb216f3f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -179,7 +179,7 @@ jobs: uses: hiero-ledger/hiero-solo-action@4d42a74e8e644a2753f3bb7a2afa429305375b14 # v0.16 with: installMirrorNode: true - mirrorNodeVersion: v0.145.2 + mirrorNodeVersion: v0.152.0 hieroVersion: v0.70.0-rc.2 - name: Build SDK @@ -254,7 +254,7 @@ jobs: installMirrorNode: true dualMode: true hieroVersion: v0.68.0 - mirrorNodeVersion: v0.142.0 + mirrorNodeVersion: v0.152.0 - name: Build SDK run: ./gradlew assemble @@ -309,7 +309,7 @@ jobs: uses: hiero-ledger/hiero-solo-action@4d42a74e8e644a2753f3bb7a2afa429305375b14 # v0.16 with: installMirrorNode: true - mirrorNodeVersion: v0.145.2 + mirrorNodeVersion: v0.152.0 hieroVersion: v0.70.0-rc.2 - name: Build SDK 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 index 314bf0ded..5a239919e 100644 --- a/examples/src/main/java/com/hedera/hashgraph/sdk/examples/FeeEstimateQueryExample.java +++ b/examples/src/main/java/com/hedera/hashgraph/sdk/examples/FeeEstimateQueryExample.java @@ -110,7 +110,7 @@ private static FeeEstimateResponse estimateWithStateMode(Client client, Transfer printNodeFee(stateEstimate); printServiceFee(stateEstimate); printTotalFee(stateEstimate); - printNotes(stateEstimate); + System.out.println("\nHigh Volume Multiplier: " + stateEstimate.getHighVolumeMultiplier()); return stateEstimate; } @@ -148,15 +148,6 @@ private static void printTotalFee(FeeEstimateResponse estimate) { System.out.println("Total Estimated Fee: " + Hbar.fromTinybars(estimate.getTotal() / 100)); } - private static void printNotes(FeeEstimateResponse estimate) { - if (!estimate.getNotes().isEmpty()) { - System.out.println("\nNotes:"); - for (String note : estimate.getNotes()) { - System.out.println(" - " + note); - } - } - } - private static FeeEstimateResponse estimateWithIntrinsicMode(Client client, TransferTransaction tx) throws Exception { System.out.println("\n=== Estimating Fees with INTRINSIC Mode ==="); diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateMode.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateMode.java index bec12a5f6..1fca0c4b5 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateMode.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateMode.java @@ -8,7 +8,7 @@ */ public enum FeeEstimateMode { /** - * Default mode: uses latest known state. + * 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 @@ -17,7 +17,7 @@ public enum FeeEstimateMode { STATE(0), /** - * Intrinsic mode: ignores state-dependent factors. + * Default mode: ignores state-dependent factors. *

* This mode calculates fees based only on the intrinsic properties of * the transaction itself, ignoring dynamic network conditions. This diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateQuery.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateQuery.java index f2c2e859d..d362c9c47 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateQuery.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateQuery.java @@ -30,6 +30,7 @@ public class FeeEstimateQuery { @Nullable private com.hedera.hashgraph.sdk.proto.Transaction transaction = null; + private int highVolumeThrottle = 0; private int maxAttempts = 10; private Duration maxBackoff = Duration.ofSeconds(8L); @@ -64,7 +65,7 @@ public FeeEstimateMode getMode() { /** * Set the mode for fee estimation. *

- * Defaults to {@link FeeEstimateMode#STATE} if not set. + * Defaults to {@link FeeEstimateMode#INTRINSIC} if not set. * * @param mode the fee estimate mode * @return {@code this} @@ -75,6 +76,32 @@ public FeeEstimateQuery setMode(FeeEstimateMode mode) { return this; } + /** + * Extract the high-volume throttle utilization in basis points. + * + * @return the high-volume throttle value (0–10000) + */ + public int getHighVolumeThrottle() { + return highVolumeThrottle; + } + + /** + * Set the high-volume throttle utilization in basis points (0–10000, where 10000 = 100%). + *

+ * When non-zero, the mirror node returns a high-volume pricing multiplier + * in the response. + * + * @param highVolumeThrottle the throttle utilization in basis points + * @return {@code this} + */ + public FeeEstimateQuery setHighVolumeThrottle(int highVolumeThrottle) { + if (highVolumeThrottle < 0 || highVolumeThrottle > 10000) { + throw new IllegalArgumentException("highVolumeThrottle must be between 0 and 10000"); + } + this.highVolumeThrottle = highVolumeThrottle; + return this; + } + /** * Extract the transaction to estimate fees for. * @@ -178,7 +205,7 @@ public FeeEstimateResponse execute(Client client) throws IOException, Interrupte * @throws InterruptedException if the operation is interrupted */ public FeeEstimateResponse execute(Client client, Duration timeout) throws IOException, InterruptedException { - var resolvedMode = mode != null ? mode : FeeEstimateMode.STATE; + var resolvedMode = mode != null ? mode : FeeEstimateMode.INTRINSIC; var requestPayload = getRequestPayload(); var url = buildUrl(client, resolvedMode); @@ -253,7 +280,7 @@ public CompletableFuture executeAsync(Client client) { * @return the fee estimate response */ public CompletableFuture executeAsync(Client client, Duration timeout) { - var resolvedMode = mode != null ? mode : FeeEstimateMode.STATE; + var resolvedMode = mode != null ? mode : FeeEstimateMode.INTRINSIC; CompletableFuture returnFuture = new CompletableFuture<>(); executeAsync(client, timeout, resolvedMode, returnFuture, 1); return returnFuture; @@ -352,7 +379,11 @@ private byte[] getRequestPayload() { private String buildUrl(Client client, FeeEstimateMode resolvedMode) { // Keep mode casing consistent with JS SDK (uppercase) - return client.getMirrorRestBaseUrl() + "/network/fees?mode=" + resolvedMode.toString(); + String url = client.getMirrorRestBaseUrl() + "/network/fees?mode=" + resolvedMode.toString(); + if (highVolumeThrottle > 0) { + url += "&high_volume_throttle=" + highVolumeThrottle; + } + return url; } private HttpRequest buildHttpRequest(String url, Duration timeout, byte[] payload) { diff --git a/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateResponse.java b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateResponse.java index 5d646a301..146f476ed 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateResponse.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeEstimateResponse.java @@ -4,9 +4,6 @@ import com.google.common.base.MoreObjects; import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; import java.util.Objects; import javax.annotation.Nullable; @@ -46,11 +43,12 @@ public final class FeeEstimateResponse { private final FeeEstimate serviceFee; /** - * An array of strings for any caveats. + * The high-volume throttle multiplier returned by the mirror node. *

- * For example: ["Fallback to worst-case due to missing state"] + * When non-zero high-volume throttle utilization is requested, this value + * will be greater than or equal to 1. */ - private final List notes; + private final long highVolumeMultiplier; /** * The sum of the network, node, and service subtotals in tinycents. @@ -60,24 +58,24 @@ public final class FeeEstimateResponse { /** * 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 + * @param mode the fee estimate mode used + * @param networkFee the network fee component + * @param nodeFee the node fee estimate + * @param highVolumeMultiplier the high-volume throttle multiplier + * @param serviceFee the service fee estimate + * @param total the total fee in tinycents */ FeeEstimateResponse( FeeEstimateMode mode, @Nullable NetworkFee networkFee, @Nullable FeeEstimate nodeFee, - List notes, + long highVolumeMultiplier, @Nullable FeeEstimate serviceFee, long total) { this.mode = mode; this.networkFee = networkFee; this.nodeFee = nodeFee; - this.notes = Collections.unmodifiableList(new ArrayList<>(notes)); + this.highVolumeMultiplier = highVolumeMultiplier; this.serviceFee = serviceFee; this.total = total; } @@ -96,7 +94,7 @@ static FeeEstimateResponse fromJson(String json, FeeEstimateMode defaultMode) { parseModeFromJson(root, defaultMode), parseNetworkFeeFromJson(root), parseFeeEstimateFromJson(root, "node"), - parseNotesFromJson(root), + parseHighVolumeMultiplierFromJson(root), parseFeeEstimateFromJson(root, "service"), parseTotalFromJson(root)); } @@ -148,17 +146,15 @@ private static FeeEstimate parseFeeEstimateFromJson(JsonObject root, String fiel } /** - * Parse notes from JSON. + * Parse high-volume multiplier from JSON. * * @param root the JSON object - * @return the list of notes + * @return the high-volume multiplier value, or 0 if not present */ - private static List parseNotesFromJson(JsonObject root) { - List notes = new ArrayList<>(); - if (root.has("notes") && root.get("notes").isJsonArray()) { - root.getAsJsonArray("notes").forEach(element -> notes.add(element.getAsString())); - } - return notes; + private static long parseHighVolumeMultiplierFromJson(JsonObject root) { + return root.has("high_volume_multiplier") + ? root.get("high_volume_multiplier").getAsLong() + : 0L; } /** @@ -201,12 +197,12 @@ public FeeEstimate getNodeFee() { } /** - * Extract the list of notes/caveats. + * Extract the high-volume throttle multiplier. * - * @return an unmodifiable list of notes + * @return the high-volume multiplier */ - public List getNotes() { - return notes; + public long getHighVolumeMultiplier() { + return highVolumeMultiplier; } /** @@ -234,7 +230,7 @@ public String toString() { .add("mode", mode) .add("network", networkFee) .add("node", nodeFee) - .add("notes", notes) + .add("highVolumeMultiplier", highVolumeMultiplier) .add("service", serviceFee) .add("total", total) .toString(); @@ -250,14 +246,14 @@ public boolean equals(Object o) { } return total == that.total && mode == that.mode + && highVolumeMultiplier == that.highVolumeMultiplier && 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); + return Objects.hash(mode, networkFee, nodeFee, highVolumeMultiplier, 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 index db2676990..1da82504a 100644 --- a/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeExtra.java +++ b/sdk/src/main/java/com/hedera/hashgraph/sdk/FeeExtra.java @@ -15,12 +15,12 @@ public final class FeeExtra { /** * The charged count of items as calculated by max(0, count - included). */ - private final int charged; + private final long charged; /** * The actual count of items received. */ - private final int count; + private final long count; /** * The fee price per unit in tinycents. @@ -30,7 +30,7 @@ public final class FeeExtra { /** * The count of this "extra" that is included for free. */ - private final int included; + private final long included; /** * The unique name of this extra fee as defined in the fee schedule. @@ -55,7 +55,7 @@ public final class FeeExtra { * @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) { + FeeExtra(long charged, long count, long feePerUnit, long included, @Nullable String name, long subtotal) { this.charged = charged; this.count = count; this.feePerUnit = feePerUnit; @@ -71,10 +71,10 @@ public final class FeeExtra { * @return the new FeeExtra */ static FeeExtra fromJson(com.google.gson.JsonObject feeExtra) { - int charged = getInt(feeExtra, "charged"); - int count = getInt(feeExtra, "count"); + long charged = getLong(feeExtra, "charged"); + long count = getLong(feeExtra, "count"); long feePerUnit = getLong(feeExtra, "fee_per_unit"); - int included = getInt(feeExtra, "included"); + long included = getLong(feeExtra, "included"); String name = feeExtra.has("name") && !feeExtra.get("name").isJsonNull() ? feeExtra.get("name").getAsString() : null; @@ -88,7 +88,7 @@ static FeeExtra fromJson(com.google.gson.JsonObject feeExtra) { * * @return the charged count of items */ - public int getCharged() { + public long getCharged() { return charged; } @@ -97,7 +97,7 @@ public int getCharged() { * * @return the actual count of items */ - public int getCount() { + public long getCount() { return count; } @@ -115,7 +115,7 @@ public long getFeePerUnit() { * * @return the count included for free */ - public int getIncluded() { + public long getIncluded() { return included; } @@ -171,10 +171,6 @@ public int hashCode() { return Objects.hash(charged, count, feePerUnit, included, name, subtotal); } - private static int getInt(com.google.gson.JsonObject object, String key) { - return object.get(key).getAsInt(); - } - private static long getLong(com.google.gson.JsonObject object, String key) { return object.get(key).getAsLong(); } diff --git a/sdk/src/test/java/com/hedera/hashgraph/sdk/FeeEstimateQueryMockTest.java b/sdk/src/test/java/com/hedera/hashgraph/sdk/FeeEstimateQueryMockTest.java index c19a7c759..1655ad8c0 100644 --- a/sdk/src/test/java/com/hedera/hashgraph/sdk/FeeEstimateQueryMockTest.java +++ b/sdk/src/test/java/com/hedera/hashgraph/sdk/FeeEstimateQueryMockTest.java @@ -94,6 +94,48 @@ void succeedsOnFirstAttempt() throws IOException, InterruptedException { assertThat(response.getMode()).isEqualTo(FeeEstimateMode.INTRINSIC); assertThat(response.getTotal()).isEqualTo(3 * 10 + 10 + 20); + assertThat(response.getHighVolumeMultiplier()).isEqualTo(1); + assertThat(stub.requestCount()).isEqualTo(1); + assertThat(stub.getLastQueryParams()).doesNotContain("high_volume_throttle"); + } + + @Test + @DisplayName("Given a FeeEstimateQuery without explicit mode, it defaults to INTRINSIC") + void defaultsToIntrinsic() throws IOException, InterruptedException { + query.setTransaction(DUMMY_TRANSACTION); + + stub.enqueue(new StubResponse(200, newSuccessResponse(FeeEstimateMode.INTRINSIC, 3, 10, 20))); + + var response = query.execute(client); + + assertThat(stub.getLastQueryParams()).contains("mode=INTRINSIC"); + assertThat(response.getMode()).isEqualTo(FeeEstimateMode.INTRINSIC); + } + + @Test + @DisplayName("Given a FeeEstimateQuery with high volume throttle, it sends the parameter in the URL") + void sendsHighVolumeThrottle() throws IOException, InterruptedException { + query.setTransaction(DUMMY_TRANSACTION).setHighVolumeThrottle(5000); + + stub.enqueue(new StubResponse(200, newSuccessResponse(FeeEstimateMode.INTRINSIC, 3, 10, 20))); + + var response = query.execute(client); + + assertThat(stub.getLastQueryParams()).contains("high_volume_throttle=5000"); + assertThat(response.getHighVolumeMultiplier()).isGreaterThanOrEqualTo(1); + } + + @Test + @DisplayName("Given a FeeEstimateQuery receives HTTP 400, it does not retry") + void doesNotRetryOn400() { + query.setTransaction(DUMMY_TRANSACTION).setMaxAttempts(3); + + stub.enqueue(new StubResponse(400, "bad request")); + + org.assertj.core.api.Assertions.assertThatThrownBy(() -> query.execute(client)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("400"); + assertThat(stub.requestCount()).isEqualTo(1); } @@ -107,11 +149,10 @@ private static String newSuccessResponse( "network": {"multiplier": %d, "subtotal": %d}, "node": {"base": %d, "extras": []}, "service": {"base": %d, "extras": []}, - "notes": [], + "high_volume_multiplier": 1, "total": %d } - """ - .formatted(mode, networkMultiplier, networkSubtotal, nodeBase, serviceBase, total); + """.formatted(mode, networkMultiplier, networkSubtotal, nodeBase, serviceBase, total); } private static final class StubResponse { @@ -127,6 +168,7 @@ private static final class StubResponse { private static final class StubMirrorRestServer { private final Queue responses = new ArrayDeque<>(); private int observedRequests = 0; + private String lastQueryParams; private HttpServer server; private int port; @@ -135,6 +177,7 @@ void start() throws IOException { port = server.getAddress().getPort(); server.createContext("/api/v1/network/fees", exchange -> { observedRequests++; + lastQueryParams = exchange.getRequestURI().getQuery(); var response = responses.poll(); assertThat(response) .as("response should be queued before invoking network fee estimation") @@ -176,6 +219,10 @@ int getPort() { return port; } + String getLastQueryParams() { + return lastQueryParams; + } + void verify() { assertThat(responses) .as("all queued responses should have been served") 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 index 1ba0894db..4458ed891 100644 --- 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 @@ -107,8 +107,8 @@ void transferTransactionIntrinsicModeFeeEstimate() throws Throwable { @Test @DisplayName( - "Given a TransferTransaction without explicit mode, when fee estimate is requested, then STATE mode is used by default") - void transferTransactionDefaultModeIsState() throws Throwable { + "Given a TransferTransaction without explicit mode, when fee estimate is requested, then INTRINSIC mode is used by default") + void transferTransactionDefaultModeIsIntrinsic() throws Throwable { try (var testEnv = createFeeEstimateTestEnv()) { var transaction = new TransferTransaction() .addHbarTransfer(testEnv.operatorId, Hbar.fromTinybars(-1)) @@ -121,7 +121,31 @@ void transferTransactionDefaultModeIsState() throws Throwable { var response = new FeeEstimateQuery().setTransaction(transaction).execute(testEnv.client); assertFeeComponentsPresent(response); - assertThat(response.getMode()).isEqualTo(FeeEstimateMode.STATE); + assertThat(response.getMode()).isEqualTo(FeeEstimateMode.INTRINSIC); + assertComponentTotalsConsistent(response); + } + } + + @Test + @DisplayName( + "Given a TransferTransaction with high volume throttle, when fee estimate is requested, then high volume multiplier is returned") + void feeEstimateQueryWithHighVolumeThrottle() throws Throwable { + try (var testEnv = createFeeEstimateTestEnv()) { + 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) + .setHighVolumeThrottle(5000) + .execute(testEnv.client); + + assertFeeComponentsPresent(response); + assertThat(response.getHighVolumeMultiplier()).isGreaterThanOrEqualTo(1); assertComponentTotalsConsistent(response); } } @@ -382,8 +406,8 @@ private static void assertFeeComponentsPresent(FeeEstimateResponse response) { assertThat(response.getServiceFee().getBase()).isGreaterThanOrEqualTo(0); assertThat(response.getServiceFee().getExtras()).isNotNull(); - // Notes and total - assertThat(response.getNotes()).isNotNull(); + // High volume multiplier and total + assertThat(response.getHighVolumeMultiplier()).isGreaterThanOrEqualTo(1); assertThat(response.getTotal()).isGreaterThan(0); }