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    /// Cosmos REST (LCD) endpoint.
23    pub rest: Option<String>,
24    /// Cosmos 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.
164const STATIC_FALLBACK_GAS_PRICE: &str = "0.025";
165const STATIC_FALLBACK_DENOM: &str = "uqor";
166const STATIC_FALLBACK_GAS: &str = "200000";
167
168impl Fees {
169    /// Estimates a fee for the given urgency via the AI fee oracle, falling back
170    /// to a deterministic static fee when the oracle is unavailable. The returned
171    /// value is a Cosmos `StdFee`-shaped JSON document
172    /// (`{"amount":[...],"gas":...}`).
173    pub async fn estimate(&self, urgency: &str) -> Result<Value> {
174        let urgency = if urgency.is_empty() {
175            "normal"
176        } else {
177            urgency
178        };
179        if let Ok(raw) = self.rest.get_fee_estimate(urgency).await {
180            if let Some(amount) = raw
181                .get("suggested_fee_uqor")
182                .map(value_to_amount_string)
183                .filter(|a| !a.is_empty() && a != "0")
184            {
185                return static_fee(STATIC_FALLBACK_GAS, "", STATIC_FALLBACK_DENOM, &amount);
186            }
187        }
188        static_fee(
189            STATIC_FALLBACK_GAS,
190            STATIC_FALLBACK_GAS_PRICE,
191            STATIC_FALLBACK_DENOM,
192            "",
193        )
194    }
195}
196
197fn value_to_amount_string(v: &Value) -> String {
198    match v {
199        Value::String(s) => s.clone(),
200        Value::Number(n) => n.to_string(),
201        _ => String::new(),
202    }
203}
204
205/// Builds a `StdFee` JSON doc. When `amount` is non-empty it is used directly;
206/// otherwise the fee is computed as `ceil(gas * gas_price)`.
207fn static_fee(gas: &str, gas_price: &str, denom: &str, amount: &str) -> Result<Value> {
208    let amount = if amount.is_empty() {
209        compute_ceil_fee(gas, gas_price)?
210    } else {
211        amount.to_string()
212    };
213    Ok(json!({
214        "amount": [{ "denom": denom, "amount": amount }],
215        "gas": gas,
216    }))
217}
218
219/// Returns `ceil(gas * gas_price)` using integer (`u128`) math to avoid
220/// floating-point drift. `gas_price` is a non-negative decimal string.
221fn compute_ceil_fee(gas: &str, gas_price: &str) -> Result<String> {
222    let gas_units: u128 = gas
223        .parse()
224        .map_err(|_| Error::Denom(format!("invalid gas: {gas}")))?;
225    let (int_part, frac_part) = match gas_price.split_once('.') {
226        Some((i, f)) => (i, f),
227        None => (gas_price, ""),
228    };
229    let ip: u128 = or_zero(int_part)
230        .parse()
231        .map_err(|_| Error::Denom(format!("invalid gas price: {gas_price}")))?;
232    let fp: u128 = or_zero(frac_part)
233        .parse()
234        .map_err(|_| Error::Denom(format!("invalid gas price: {gas_price}")))?;
235    let scale = 10u128.pow(frac_part.len() as u32);
236    let numerator = ip * scale + fp;
237    let raw = gas_units * numerator;
238    // ceil division.
239    Ok(raw.div_ceil(scale).to_string())
240}
241
242fn or_zero(s: &str) -> &str {
243    if s.is_empty() {
244        "0"
245    } else {
246        s
247    }
248}
249
250fn resolve_network(
251    network: &str,
252    overrides: &EndpointOverrides,
253    chain_id: Option<&str>,
254) -> Result<NetworkConfig> {
255    // Live preset (testnet or mainnet): start from it, then overlay endpoint
256    // overrides onto the defaults.
257    let mut resolved = get_network(network)?;
258    if let Some(eps) = resolved.endpoints.as_mut() {
259        overlay(eps, overrides);
260    }
261    if let Some(cid) = chain_id {
262        resolved.chain_id = Some(cid.to_string());
263    }
264    Ok(resolved)
265}
266
267fn overlay(eps: &mut Endpoints, o: &EndpointOverrides) {
268    if let Some(v) = &o.rest {
269        eps.rest = v.clone();
270    }
271    if let Some(v) = &o.grpc {
272        eps.grpc = v.clone();
273    }
274    if let Some(v) = &o.rpc {
275        eps.rpc = v.clone();
276    }
277    if let Some(v) = &o.evm_rpc {
278        eps.evm_rpc = v.clone();
279    }
280    if let Some(v) = &o.evm_ws {
281        eps.evm_ws = v.clone();
282    }
283    if let Some(v) = &o.svm_rpc {
284        eps.svm_rpc = v.clone();
285    }
286}
287
288fn require_endpoint(key: &str, value: &str) -> Result<String> {
289    if value.is_empty() {
290        return Err(Error::MissingEndpoint(key.to_string()));
291    }
292    Ok(value.to_string())
293}
294
295/// Creates a composed [`Client`] for the given network using default localhost
296/// endpoints (testnet) or the supplied overrides.
297///
298/// This is a convenience wrapper over [`ClientBuilder`]. It returns an error if
299/// mainnet is selected without endpoints, or if a required endpoint is missing.
300pub fn create_client(network: &str) -> Result<Client> {
301    ClientBuilder::new().network(network).build()
302}