Skip to main content

hiero_sdk/
fee_estimate_query.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use std::time::Duration;
4
5use bytes::Bytes;
6use http_body_util::BodyExt as _;
7use hyper::body::Incoming;
8use hyper::{
9    Method,
10    Request,
11    Response,
12};
13use hyper_openssl::client::legacy::HttpsConnector;
14use hyper_util::client::legacy::connect::HttpConnector;
15use hyper_util::client::legacy::Client as HttpClient;
16use hyper_util::rt::TokioExecutor;
17use openssl::ssl::{
18    SslConnector,
19    SslMethod,
20    SslVerifyMode,
21};
22
23use crate::fee_estimate_types::{
24    FeeEstimate,
25    FeeEstimateResponse,
26    FeeExtra,
27    NetworkFee,
28};
29use crate::transaction::TransactionExecute;
30use crate::{
31    Client,
32    Error,
33    FeeEstimateMode,
34    Transaction,
35};
36
37/// Default maximum number of retry attempts.
38const DEFAULT_MAX_ATTEMPTS: usize = 10;
39
40/// Default maximum backoff duration.
41const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(8);
42
43/// Initial backoff delay.
44const INITIAL_BACKOFF: Duration = Duration::from_millis(500);
45
46/// Fee estimation query that communicates with the mirror node REST API.
47///
48/// This query estimates the expected transaction fees without submitting the transaction
49/// to the network, per HIP-1261.
50///
51/// # Examples
52/// ```no_run
53/// # async fn example() -> hiero_sdk::Result<()> {
54/// use hiero_sdk::{Client, AccountId, PrivateKey, TransferTransaction, FeeEstimateQuery, FeeEstimateMode};
55///
56/// let client = Client::for_testnet();
57/// client.set_operator(AccountId::new(0, 0, 1234), PrivateKey::generate_ed25519());
58///
59/// let mut tx = TransferTransaction::new();
60/// // ... configure transaction ...
61///
62/// let response = FeeEstimateQuery::new()
63///     .set_transaction(&mut tx, &client)?
64///     .set_mode(FeeEstimateMode::Intrinsic)
65///     .execute(&client)
66///     .await?;
67///
68/// println!("Estimated fee: {} tinycents", response.total);
69/// # Ok(())
70/// # }
71/// ```
72pub struct FeeEstimateQuery {
73    mode: FeeEstimateMode,
74    transaction_bytes: Option<Vec<u8>>,
75    high_volume_throttle: Option<u32>,
76    max_attempts: usize,
77    max_backoff: Duration,
78}
79
80impl Default for FeeEstimateQuery {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86impl FeeEstimateQuery {
87    /// Creates a new fee estimate query with default settings.
88    #[must_use]
89    pub fn new() -> Self {
90        Self {
91            mode: FeeEstimateMode::default(),
92            transaction_bytes: None,
93            high_volume_throttle: None,
94            max_attempts: DEFAULT_MAX_ATTEMPTS,
95            max_backoff: DEFAULT_MAX_BACKOFF,
96        }
97    }
98
99    /// Sets the fee estimation mode.
100    #[must_use]
101    pub fn set_mode(mut self, mode: FeeEstimateMode) -> Self {
102        self.mode = mode;
103        self
104    }
105
106    /// Returns the fee estimation mode.
107    #[must_use]
108    pub fn get_mode(&self) -> FeeEstimateMode {
109        self.mode
110    }
111
112    /// Sets the transaction to estimate fees for.
113    ///
114    /// The transaction will be automatically frozen with the given client if not already frozen.
115    /// For chunked transactions, only the first chunk is used for fee estimation.
116    ///
117    /// # Errors
118    /// - If the transaction cannot be frozen.
119    /// - If the transaction bytes cannot be serialized.
120    pub fn set_transaction<D: TransactionExecute>(
121        mut self,
122        transaction: &mut Transaction<D>,
123        client: &Client,
124    ) -> crate::Result<Self> {
125        transaction.freeze_with(Some(client))?;
126        self.transaction_bytes = Some(transaction.to_bytes()?);
127        Ok(self)
128    }
129
130    /// Sets the protobuf-encoded transaction bytes to estimate fees for.
131    ///
132    /// The transaction must be frozen before calling `to_bytes()`.
133    #[must_use]
134    pub fn set_transaction_bytes(mut self, bytes: Vec<u8>) -> Self {
135        self.transaction_bytes = Some(bytes);
136        self
137    }
138
139    /// Returns the transaction bytes, if set.
140    #[must_use]
141    pub fn get_transaction_bytes(&self) -> Option<&[u8]> {
142        self.transaction_bytes.as_deref()
143    }
144
145    /// Sets the high-volume throttle utilization in basis points (0–10000).
146    ///
147    /// This simulates throttle utilization for high-volume pricing per HIP-1313
148    /// without impacting the actual network.
149    ///
150    /// # Panics
151    /// Panics if `basis_points` is greater than 10000.
152    #[must_use]
153    pub fn set_high_volume_throttle(mut self, basis_points: u32) -> Self {
154        assert!(basis_points <= 10000, "high_volume_throttle must be between 0 and 10000");
155        self.high_volume_throttle = Some(basis_points);
156        self
157    }
158
159    /// Returns the high-volume throttle, if set.
160    #[must_use]
161    pub fn get_high_volume_throttle(&self) -> Option<u32> {
162        self.high_volume_throttle
163    }
164
165    /// Sets the maximum number of retry attempts.
166    #[must_use]
167    pub fn set_max_attempts(mut self, max_attempts: usize) -> Self {
168        self.max_attempts = max_attempts;
169        self
170    }
171
172    /// Returns the maximum number of retry attempts.
173    #[must_use]
174    pub fn get_max_attempts(&self) -> usize {
175        self.max_attempts
176    }
177
178    /// Sets the maximum backoff duration between retries.
179    ///
180    /// # Panics
181    /// Panics if `max_backoff` is less than 500ms.
182    #[must_use]
183    pub fn set_max_backoff(mut self, max_backoff: Duration) -> Self {
184        assert!(max_backoff >= Duration::from_millis(500), "max_backoff must be at least 500ms");
185        self.max_backoff = max_backoff;
186        self
187    }
188
189    /// Returns the maximum backoff duration.
190    #[must_use]
191    pub fn get_max_backoff(&self) -> Duration {
192        self.max_backoff
193    }
194
195    /// Executes the fee estimate query against the mirror node REST API.
196    ///
197    /// # Errors
198    /// - If no transaction bytes have been set.
199    /// - If the mirror node returns an unrecoverable error.
200    /// - If all retry attempts are exhausted.
201    pub async fn execute(&self, client: &Client) -> crate::Result<FeeEstimateResponse> {
202        let transaction_bytes = self.transaction_bytes.as_ref().ok_or_else(|| {
203            Error::basic_parse("transaction bytes must be set on FeeEstimateQuery")
204        })?;
205
206        let base_url = mirror_rest_base_url(client);
207        let url = self.build_url(&base_url);
208
209        let http_client = build_http_client(&base_url);
210
211        let mut attempt = 0;
212        loop {
213            let request = Request::builder()
214                .method(Method::POST)
215                .uri(&url)
216                .header("Content-Type", "application/x-protobuf")
217                .header("Accept", "application/json")
218                .body(http_body_util::Full::new(Bytes::from(transaction_bytes.clone())))
219                .map_err(|e| Error::basic_parse(e.to_string()))?;
220
221            let result = http_client.request(request).await;
222
223            match result {
224                Ok(response) => {
225                    let status = response.status();
226                    if status.is_success() {
227                        return parse_response(response).await;
228                    }
229
230                    if should_retry_status(status) && attempt < self.max_attempts {
231                        attempt += 1;
232                        let delay = compute_backoff(attempt, self.max_backoff);
233                        tokio::time::sleep(delay).await;
234                        continue;
235                    }
236
237                    let body = read_body(response).await.unwrap_or_default();
238                    return Err(Error::basic_parse(format!(
239                        "fee estimate query failed with HTTP {status}: {body}"
240                    )));
241                }
242                Err(e) => {
243                    if attempt < self.max_attempts {
244                        attempt += 1;
245                        let delay = compute_backoff(attempt, self.max_backoff);
246                        tokio::time::sleep(delay).await;
247                        continue;
248                    }
249                    return Err(Error::basic_parse(format!(
250                        "fee estimate query failed after {attempt} attempts: {e}"
251                    )));
252                }
253            }
254        }
255    }
256
257    fn build_url(&self, base_url: &str) -> String {
258        let mut url = format!("{base_url}/network/fees?mode={}", self.mode.as_str());
259        if let Some(throttle) = self.high_volume_throttle {
260            url.push_str(&format!("&highVolumeThrottle={throttle}"));
261        }
262        url
263    }
264}
265
266/// Determines whether a response status code is retryable.
267fn should_retry_status(status: hyper::StatusCode) -> bool {
268    matches!(status.as_u16(), 408 | 429 | 500 | 502 | 503 | 504)
269}
270
271/// Computes exponential backoff delay: `min(500ms * 2^attempt, max_backoff)`.
272fn compute_backoff(attempt: usize, max_backoff: Duration) -> Duration {
273    let delay = INITIAL_BACKOFF.saturating_mul(1u32.wrapping_shl(attempt as u32));
274    delay.min(max_backoff)
275}
276
277/// Constructs the mirror node REST base URL from the client's mirror network configuration.
278fn mirror_rest_base_url(client: &Client) -> String {
279    let addresses = client.mirror_network();
280    let address = addresses.first().expect("mirror network must have at least one address");
281
282    // Parse host and port
283    let (host, port_str) = if let Some(idx) = address.rfind(':') {
284        (&address[..idx], &address[idx + 1..])
285    } else {
286        (address.as_str(), "443")
287    };
288
289    let port: u16 = port_str.parse().unwrap_or(443);
290
291    let is_localhost = host.contains("localhost") || host.contains("127.0.0.1");
292
293    if is_localhost {
294        // For local development, map gRPC port 5600 to REST port 5551
295        let rest_port = if port == 5600 { 5551 } else { port };
296        format!("http://{host}:{rest_port}/api/v1")
297    } else {
298        let scheme = if port == 80 { "http" } else { "https" };
299        if (scheme == "https" && port == 443) || (scheme == "http" && port == 80) {
300            format!("{scheme}://{host}/api/v1")
301        } else {
302            format!("{scheme}://{host}:{port}/api/v1")
303        }
304    }
305}
306
307/// Builds an HTTP client appropriate for the given base URL.
308fn build_http_client(
309    base_url: &str,
310) -> HttpClient<HttpsConnector<HttpConnector>, http_body_util::Full<Bytes>> {
311    let mut http = HttpConnector::new();
312    http.enforce_http(false);
313
314    if base_url.starts_with("https") {
315        let mut ssl_builder = SslConnector::builder(SslMethod::tls()).unwrap();
316        ssl_builder.set_verify(SslVerifyMode::PEER);
317        let https = HttpsConnector::with_connector(http, ssl_builder).unwrap();
318        HttpClient::builder(TokioExecutor::new()).build(https)
319    } else {
320        // For HTTP (localhost), still use HttpsConnector but with permissive settings
321        let mut ssl_builder = SslConnector::builder(SslMethod::tls()).unwrap();
322        ssl_builder.set_verify(SslVerifyMode::NONE);
323        let https = HttpsConnector::with_connector(http, ssl_builder).unwrap();
324        HttpClient::builder(TokioExecutor::new()).build(https)
325    }
326}
327
328/// Reads the full body of an HTTP response as a string.
329async fn read_body(response: Response<Incoming>) -> Result<String, String> {
330    let body_bytes = response.into_body().collect().await.map_err(|e| e.to_string())?.to_bytes();
331    String::from_utf8(body_bytes.to_vec()).map_err(|e| e.to_string())
332}
333
334/// Parses a successful fee estimate response from JSON.
335async fn parse_response(response: Response<Incoming>) -> crate::Result<FeeEstimateResponse> {
336    let body = read_body(response)
337        .await
338        .map_err(|e| Error::basic_parse(format!("failed to read response body: {e}")))?;
339
340    parse_fee_estimate_response_json(&body)
341}
342
343fn parse_fee_estimate_response_json(json: &str) -> crate::Result<FeeEstimateResponse> {
344    let value: serde_json::Value = serde_json::from_str(json)
345        .map_err(|e| Error::basic_parse(format!("failed to parse fee estimate JSON: {e}")))?;
346
347    let high_volume_multiplier = value
348        .get("high_volume_multiplier")
349        .or_else(|| value.get("highVolumeMultiplier"))
350        .and_then(|v| v.as_u64())
351        .unwrap_or(1);
352
353    let network = value.get("network").and_then(|v| parse_network_fee(v));
354
355    let node = value.get("node").and_then(|v| parse_fee_estimate(v));
356
357    let service = value.get("service").and_then(|v| parse_fee_estimate(v));
358
359    let total = value.get("total").and_then(|v| v.as_u64()).unwrap_or(0);
360
361    Ok(FeeEstimateResponse { high_volume_multiplier, network, node, service, total })
362}
363
364fn parse_network_fee(value: &serde_json::Value) -> Option<NetworkFee> {
365    if value.is_null() {
366        return None;
367    }
368    Some(NetworkFee {
369        multiplier: value.get("multiplier").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
370        subtotal: value.get("subtotal").and_then(|v| v.as_u64()).unwrap_or(0),
371    })
372}
373
374fn parse_fee_estimate(value: &serde_json::Value) -> Option<FeeEstimate> {
375    if value.is_null() {
376        return None;
377    }
378    let base = value.get("base").and_then(|v| v.as_u64()).unwrap_or(0);
379    let extras = value
380        .get("extras")
381        .and_then(|v| v.as_array())
382        .map(|arr| arr.iter().filter_map(parse_fee_extra).collect())
383        .unwrap_or_default();
384
385    Some(FeeEstimate { base, extras })
386}
387
388fn parse_fee_extra(value: &serde_json::Value) -> Option<FeeExtra> {
389    if !value.is_object() {
390        return None;
391    }
392    Some(FeeExtra {
393        name: value.get("name").and_then(|v| v.as_str()).map(String::from),
394        included: value.get("included").and_then(|v| v.as_u64()).unwrap_or(0),
395        count: value.get("count").and_then(|v| v.as_u64()).unwrap_or(0),
396        charged: value.get("charged").and_then(|v| v.as_u64()).unwrap_or(0),
397        fee_per_unit: value
398            .get("fee_per_unit")
399            .or_else(|| value.get("feePerUnit"))
400            .and_then(|v| v.as_u64())
401            .unwrap_or(0),
402        subtotal: value.get("subtotal").and_then(|v| v.as_u64()).unwrap_or(0),
403    })
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn parse_fee_estimate_response() {
412        let json = r#"{
413            "high_volume_multiplier": 1,
414            "network": {
415                "multiplier": 2,
416                "subtotal": 1000
417            },
418            "node": {
419                "base": 500,
420                "extras": [
421                    {
422                        "name": "SIGNATURE_VERIFICATION",
423                        "included": 1,
424                        "count": 2,
425                        "charged": 1,
426                        "fee_per_unit": 100,
427                        "subtotal": 100
428                    }
429                ]
430            },
431            "service": {
432                "base": 300,
433                "extras": []
434            },
435            "total": 1800
436        }"#;
437
438        let response = parse_fee_estimate_response_json(json).unwrap();
439        assert_eq!(response.high_volume_multiplier, 1);
440        assert_eq!(response.total, 1800);
441
442        let network = response.network.unwrap();
443        assert_eq!(network.multiplier, 2);
444        assert_eq!(network.subtotal, 1000);
445
446        let node = response.node.unwrap();
447        assert_eq!(node.base, 500);
448        assert_eq!(node.extras.len(), 1);
449        assert_eq!(node.extras[0].name.as_deref(), Some("SIGNATURE_VERIFICATION"));
450        assert_eq!(node.extras[0].charged, 1);
451        assert_eq!(node.extras[0].fee_per_unit, 100);
452
453        let service = response.service.unwrap();
454        assert_eq!(service.base, 300);
455        assert!(service.extras.is_empty());
456    }
457
458    #[test]
459    fn parse_camel_case_response() {
460        let json = r#"{
461            "highVolumeMultiplier": 2,
462            "network": { "multiplier": 1, "subtotal": 500 },
463            "node": { "base": 100, "extras": [{ "name": "SIG", "included": 0, "count": 1, "charged": 1, "feePerUnit": 50, "subtotal": 50 }] },
464            "service": { "base": 200, "extras": [] },
465            "total": 750
466        }"#;
467
468        let response = parse_fee_estimate_response_json(json).unwrap();
469        assert_eq!(response.high_volume_multiplier, 2);
470        assert_eq!(response.total, 750);
471        assert_eq!(response.node.unwrap().extras[0].fee_per_unit, 50);
472    }
473
474    #[test]
475    fn fee_estimate_subtotal() {
476        let estimate = FeeEstimate {
477            base: 100,
478            extras: vec![
479                FeeExtra {
480                    name: Some("a".into()),
481                    included: 0,
482                    count: 1,
483                    charged: 1,
484                    fee_per_unit: 50,
485                    subtotal: 50,
486                },
487                FeeExtra {
488                    name: None,
489                    included: 0,
490                    count: 2,
491                    charged: 2,
492                    fee_per_unit: 25,
493                    subtotal: 50,
494                },
495            ],
496        };
497        assert_eq!(estimate.subtotal(), 200);
498    }
499
500    #[test]
501    fn build_url_intrinsic() {
502        let query = FeeEstimateQuery::new();
503        let url = query.build_url("https://mirror.example.com/api/v1");
504        assert_eq!(url, "https://mirror.example.com/api/v1/network/fees?mode=INTRINSIC");
505    }
506
507    #[test]
508    fn build_url_state_with_throttle() {
509        let query =
510            FeeEstimateQuery::new().set_mode(FeeEstimateMode::State).set_high_volume_throttle(5000);
511        let url = query.build_url("https://mirror.example.com/api/v1");
512        assert_eq!(
513            url,
514            "https://mirror.example.com/api/v1/network/fees?mode=STATE&highVolumeThrottle=5000"
515        );
516    }
517
518    #[test]
519    fn mirror_rest_url_mainnet() {
520        let url = mirror_rest_base_url_from_address("mainnet-public.mirrornode.hedera.com:443");
521        assert_eq!(url, "https://mainnet-public.mirrornode.hedera.com/api/v1");
522    }
523
524    #[test]
525    fn mirror_rest_url_localhost() {
526        let url = mirror_rest_base_url_from_address("127.0.0.1:5600");
527        assert_eq!(url, "http://127.0.0.1:5551/api/v1");
528    }
529
530    #[test]
531    fn mirror_rest_url_localhost_custom_port() {
532        let url = mirror_rest_base_url_from_address("localhost:8080");
533        assert_eq!(url, "http://localhost:8080/api/v1");
534    }
535
536    /// Helper for tests that works on a single address string.
537    fn mirror_rest_base_url_from_address(address: &str) -> String {
538        let (host, port_str) = if let Some(idx) = address.rfind(':') {
539            (&address[..idx], &address[idx + 1..])
540        } else {
541            (address, "443")
542        };
543
544        let port: u16 = port_str.parse().unwrap_or(443);
545        let is_localhost = host.contains("localhost") || host.contains("127.0.0.1");
546
547        if is_localhost {
548            let rest_port = if port == 5600 { 5551 } else { port };
549            format!("http://{host}:{rest_port}/api/v1")
550        } else {
551            let scheme = if port == 80 { "http" } else { "https" };
552            if (scheme == "https" && port == 443) || (scheme == "http" && port == 80) {
553                format!("{scheme}://{host}/api/v1")
554            } else {
555                format!("{scheme}://{host}:{port}/api/v1")
556            }
557        }
558    }
559
560    #[test]
561    fn default_settings() {
562        let query = FeeEstimateQuery::new();
563        assert_eq!(query.get_mode(), FeeEstimateMode::Intrinsic);
564        assert_eq!(query.get_max_attempts(), 10);
565        assert!(query.get_transaction_bytes().is_none());
566        assert!(query.get_high_volume_throttle().is_none());
567    }
568
569    #[test]
570    #[should_panic(expected = "high_volume_throttle must be between 0 and 10000")]
571    fn high_volume_throttle_out_of_range() {
572        let _ = FeeEstimateQuery::new().set_high_volume_throttle(10001);
573    }
574
575    #[test]
576    fn backoff_computation() {
577        assert_eq!(compute_backoff(0, Duration::from_secs(8)), Duration::from_millis(500));
578        assert_eq!(compute_backoff(1, Duration::from_secs(8)), Duration::from_millis(1000));
579        assert_eq!(compute_backoff(2, Duration::from_secs(8)), Duration::from_millis(2000));
580        assert_eq!(compute_backoff(3, Duration::from_secs(8)), Duration::from_millis(4000));
581        assert_eq!(compute_backoff(4, Duration::from_secs(8)), Duration::from_millis(8000));
582        assert_eq!(compute_backoff(5, Duration::from_secs(8)), Duration::from_millis(8000));
583    }
584}