Skip to main content

qorechain/
client.rs

1//! The top-level [`create_client`] factory and [`ClientBuilder`] for the
2//! QoreChain Rust SDK.
3//!
4//! [`create_client`] resolves a [`NetworkConfig`](crate::networks::NetworkConfig)
5//! (applying any endpoint overrides) and composes the read clients
6//! ([`RestClient`] and the `qor_*` [`QorClient`]) plus a fee-estimate
7//! convenience.
8//!
9//! Network resolution rules:
10//! - The default network is `"testnet"`. Both `"testnet"` and `"mainnet"` are
11//!   live and ship localhost endpoint defaults; callers can override them with
12//!   real hostnames.
13
14use crate::error::{Error, Result};
15use crate::networks::{get_network, Endpoints, NetworkConfig};
16use crate::query::{QorClient, RestClient};
17use serde_json::{json, Value};
18
19/// Optional per-endpoint URL overrides. `None` fields keep their preset defaults.
20#[derive(Debug, Clone, Default)]
21pub struct EndpointOverrides {
22    /// Native REST (LCD) endpoint.
23    pub rest: Option<String>,
24    /// Native gRPC endpoint.
25    pub grpc: Option<String>,
26    /// Consensus RPC endpoint.
27    pub rpc: Option<String>,
28    /// EVM JSON-RPC endpoint.
29    pub evm_rpc: Option<String>,
30    /// EVM WebSocket endpoint.
31    pub evm_ws: Option<String>,
32    /// SVM JSON-RPC endpoint.
33    pub svm_rpc: Option<String>,
34}
35
36/// Builder for a composed [`Client`].
37#[derive(Debug, Clone)]
38pub struct ClientBuilder {
39    network: String,
40    overrides: EndpointOverrides,
41    chain_id: Option<String>,
42    http: Option<reqwest::Client>,
43}
44
45impl Default for ClientBuilder {
46    fn default() -> Self {
47        Self {
48            network: "testnet".into(),
49            overrides: EndpointOverrides::default(),
50            chain_id: None,
51            http: None,
52        }
53    }
54}
55
56impl ClientBuilder {
57    /// Starts a new builder defaulting to the `testnet` network.
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Selects the network preset (default `"testnet"`).
63    pub fn network(mut self, network: impl Into<String>) -> Self {
64        self.network = network.into();
65        self
66    }
67
68    /// Overrides the REST (LCD) endpoint.
69    pub fn rest(mut self, url: impl Into<String>) -> Self {
70        self.overrides.rest = Some(url.into());
71        self
72    }
73
74    /// Overrides the gRPC endpoint.
75    pub fn grpc(mut self, url: impl Into<String>) -> Self {
76        self.overrides.grpc = Some(url.into());
77        self
78    }
79
80    /// Overrides the consensus RPC endpoint.
81    pub fn rpc(mut self, url: impl Into<String>) -> Self {
82        self.overrides.rpc = Some(url.into());
83        self
84    }
85
86    /// Overrides the EVM JSON-RPC endpoint.
87    pub fn evm_rpc(mut self, url: impl Into<String>) -> Self {
88        self.overrides.evm_rpc = Some(url.into());
89        self
90    }
91
92    /// Overrides the EVM WebSocket endpoint.
93    pub fn evm_ws(mut self, url: impl Into<String>) -> Self {
94        self.overrides.evm_ws = Some(url.into());
95        self
96    }
97
98    /// Overrides the SVM JSON-RPC endpoint.
99    pub fn svm_rpc(mut self, url: impl Into<String>) -> Self {
100        self.overrides.svm_rpc = Some(url.into());
101        self
102    }
103
104    /// Overrides the resolved chain ID (meaningful only for mainnet).
105    pub fn chain_id(mut self, chain_id: impl Into<String>) -> Self {
106        self.chain_id = Some(chain_id.into());
107        self
108    }
109
110    /// Supplies the `reqwest::Client` used for all requests. Optional.
111    pub fn http_client(mut self, http: reqwest::Client) -> Self {
112        self.http = Some(http);
113        self
114    }
115
116    /// Builds the composed [`Client`].
117    ///
118    /// Returns an error if the network is unknown or a required endpoint
119    /// (`rest`, `evm_rpc`) is missing.
120    pub fn build(self) -> Result<Client> {
121        let resolved = resolve_network(&self.network, &self.overrides, self.chain_id.as_deref())?;
122        let eps = resolved
123            .endpoints
124            .as_ref()
125            .ok_or_else(|| Error::MissingEndpoint("rest".to_string()))?;
126
127        let rest_url = require_endpoint("rest", &eps.rest)?;
128        let evm_url = require_endpoint("evm_rpc", &eps.evm_rpc)?;
129
130        let http = self.http.unwrap_or_default();
131        let rest = RestClient::with_client(rest_url, http.clone());
132        let qor = QorClient::from_jsonrpc(crate::query::JsonRpcClient::with_client(evm_url, http));
133        let fees = Fees { rest: rest.clone() };
134
135        Ok(Client {
136            network: resolved,
137            rest,
138            qor,
139            fees,
140        })
141    }
142}
143
144/// A composed QoreChain client: resolved config, read clients, fee helper.
145#[derive(Debug, Clone)]
146pub struct Client {
147    /// The resolved network configuration.
148    pub network: NetworkConfig,
149    /// REST (LCD) read client.
150    pub rest: RestClient,
151    /// `qor_*` JSON-RPC read client.
152    pub qor: QorClient,
153    /// Fee-estimate convenience.
154    pub fees: Fees,
155}
156
157/// The fee-estimate convenience surface bound to a [`RestClient`].
158#[derive(Debug, Clone)]
159pub struct Fees {
160    rest: RestClient,
161}
162
163// Static-fallback parameters used when the AI fee oracle is unavailable.
164// Above the 0.1uqor/gas genesis min-gas-price (BaseFee) enforced on both networks.
165const STATIC_FALLBACK_GAS_PRICE: &str = "0.15";
166const STATIC_FALLBACK_DENOM: &str = "uqor";
167const STATIC_FALLBACK_GAS: &str = "200000";
168
169impl Fees {
170    /// Estimates a fee for the given urgency via the AI fee oracle, falling back
171    /// to a deterministic static fee when the oracle is unavailable. The returned
172    /// value is a Native `StdFee`-shaped JSON document
173    /// (`{"amount":[...],"gas":...}`).
174    pub async fn estimate(&self, urgency: &str) -> Result<Value> {
175        let urgency = if urgency.is_empty() {
176            "normal"
177        } else {
178            urgency
179        };
180        if let Ok(raw) = self.rest.get_fee_estimate(urgency).await {
181            if let Some(amount) = raw
182                .get("suggested_fee_uqor")
183                .map(value_to_amount_string)
184                .filter(|a| !a.is_empty() && a != "0")
185            {
186                return static_fee(STATIC_FALLBACK_GAS, "", STATIC_FALLBACK_DENOM, &amount);
187            }
188        }
189        static_fee(
190            STATIC_FALLBACK_GAS,
191            STATIC_FALLBACK_GAS_PRICE,
192            STATIC_FALLBACK_DENOM,
193            "",
194        )
195    }
196}
197
198fn value_to_amount_string(v: &Value) -> String {
199    match v {
200        Value::String(s) => s.clone(),
201        Value::Number(n) => n.to_string(),
202        _ => String::new(),
203    }
204}
205
206/// Builds a `StdFee` JSON doc. When `amount` is non-empty it is used directly;
207/// otherwise the fee is computed as `ceil(gas * gas_price)`.
208fn static_fee(gas: &str, gas_price: &str, denom: &str, amount: &str) -> Result<Value> {
209    let amount = if amount.is_empty() {
210        compute_ceil_fee(gas, gas_price)?
211    } else {
212        amount.to_string()
213    };
214    Ok(json!({
215        "amount": [{ "denom": denom, "amount": amount }],
216        "gas": gas,
217    }))
218}
219
220/// Returns `ceil(gas * gas_price)` using integer (`u128`) math to avoid
221/// floating-point drift. `gas_price` is a non-negative decimal string.
222fn compute_ceil_fee(gas: &str, gas_price: &str) -> Result<String> {
223    let gas_units: u128 = gas
224        .parse()
225        .map_err(|_| Error::Denom(format!("invalid gas: {gas}")))?;
226    let (int_part, frac_part) = match gas_price.split_once('.') {
227        Some((i, f)) => (i, f),
228        None => (gas_price, ""),
229    };
230    let ip: u128 = or_zero(int_part)
231        .parse()
232        .map_err(|_| Error::Denom(format!("invalid gas price: {gas_price}")))?;
233    let fp: u128 = or_zero(frac_part)
234        .parse()
235        .map_err(|_| Error::Denom(format!("invalid gas price: {gas_price}")))?;
236    let scale = 10u128.pow(frac_part.len() as u32);
237    let numerator = ip * scale + fp;
238    let raw = gas_units * numerator;
239    // ceil division.
240    Ok(raw.div_ceil(scale).to_string())
241}
242
243fn or_zero(s: &str) -> &str {
244    if s.is_empty() {
245        "0"
246    } else {
247        s
248    }
249}
250
251fn resolve_network(
252    network: &str,
253    overrides: &EndpointOverrides,
254    chain_id: Option<&str>,
255) -> Result<NetworkConfig> {
256    // Live preset (testnet or mainnet): start from it, then overlay endpoint
257    // overrides onto the defaults.
258    let mut resolved = get_network(network)?;
259    if let Some(eps) = resolved.endpoints.as_mut() {
260        overlay(eps, overrides);
261    }
262    if let Some(cid) = chain_id {
263        resolved.chain_id = Some(cid.to_string());
264    }
265    Ok(resolved)
266}
267
268fn overlay(eps: &mut Endpoints, o: &EndpointOverrides) {
269    if let Some(v) = &o.rest {
270        eps.rest = v.clone();
271    }
272    if let Some(v) = &o.grpc {
273        eps.grpc = v.clone();
274    }
275    if let Some(v) = &o.rpc {
276        eps.rpc = v.clone();
277    }
278    if let Some(v) = &o.evm_rpc {
279        eps.evm_rpc = v.clone();
280    }
281    if let Some(v) = &o.evm_ws {
282        eps.evm_ws = v.clone();
283    }
284    if let Some(v) = &o.svm_rpc {
285        eps.svm_rpc = v.clone();
286    }
287}
288
289fn require_endpoint(key: &str, value: &str) -> Result<String> {
290    if value.is_empty() {
291        return Err(Error::MissingEndpoint(key.to_string()));
292    }
293    Ok(value.to_string())
294}
295
296/// Creates a composed [`Client`] for the given network using default localhost
297/// endpoints (testnet) or the supplied overrides.
298///
299/// This is a convenience wrapper over [`ClientBuilder`]. It returns an error if
300/// mainnet is selected without endpoints, or if a required endpoint is missing.
301pub fn create_client(network: &str) -> Result<Client> {
302    ClientBuilder::new().network(network).build()
303}