Skip to main content

sdk/
client.rs

1//! HTTP client for the Real Markets matching engine.
2//!
3//! This module provides the [`Client`] struct, which is the primary interface for
4//! communicating with the Real Markets matching engine via JSON-RPC over HTTP.
5//!
6//! The client handles all network communication, request serialization, response
7//! deserialization, and error handling automatically. It provides a simple async
8//! interface for sending transactions and querying the matching engine.
9//!
10//! # Key Features
11//!
12//! - **JSON-RPC over HTTP**: Uses standard JSON-RPC 2.0 protocol over HTTP
13//! - **Async/Await Support**: All methods are async and work with tokio
14//! - **Automatic Serialization**: Handles request/response serialization transparently
15//! - **Error Handling**: Provides detailed error context for network and API failures
16//! - **Connection Management**: Built-in connection pooling and reuse
17//!
18//! # Core Operations
19//!
20//! - [`Client::send_transaction`]: Submit signed transactions containing orders/cancellations
21//! - [`Client::get_version`]: Retrieve matching engine version information
22//! - [`Client::get_chain_id`]: Read (and cache) the chain id this endpoint expects
23//! - [`Client::verify_chain_id`]: Assert the endpoint is the network you expect
24//! - [`Client::healthcheck`]: Read the engine's height and timestamp snapshot
25//!
26//! # Usage Pattern
27//!
28//! 1. Create a client instance with [`Client::new`]
29//! 2. Build and sign transactions using the types module
30//! 3. Send transactions via [`Client::send_transaction`]
31//! 4. Handle the returned [`TransactionReport`] or errors
32//!
33//! # Examples
34//!
35//! ## Basic Client Setup
36//! ```rust,no_run
37//! use sdk::Client;
38//!
39//! #[tokio::main]
40//! async fn main() -> anyhow::Result<()> {
41//!     let client = Client::new("https://matching.api.testnet.real.xyz")?;
42//!
43//!     // the health call answers with heights and timestamps, not a bool
44//!     let health = client.healthcheck().await?;
45//!     println!(
46//!         "sequencer at height {} ({} ms)",
47//!         health.sequencer_height, health.sequencer_ts
48//!     );
49//!
50//!     Ok(())
51//! }
52//! ```
53
54use std::sync::Arc;
55
56use anyhow::Context;
57use jsonrpsee::{
58    core::{ClientError, client::ClientT},
59    http_client::HttpClient,
60    rpc_params,
61};
62use serde::{Deserialize, Serialize};
63use tokio::sync::OnceCell;
64use types::api::{Transaction, TransactionReport};
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct HealthCheck {
68    pub status: bool,
69    pub sequencer_height: u64,
70    pub sequencer_ts: u64,
71    pub settlement_chain_height: u64,
72    pub settlement_chain_ts: u64,
73}
74
75/// HTTP client for communicating with the Real Markets matching engine.
76///
77/// The `Client` provides a simple interface for sending transactions and querying
78/// the matching engine via JSON-RPC over HTTP. It handles serialization, network
79/// communication, and response parsing automatically.
80///
81/// # Examples
82///
83/// ```rust,no_run
84/// use sdk::Client;
85///
86/// # async fn example() -> anyhow::Result<()> {
87/// // Create a new client
88/// let client = Client::new("https://matching.api.testnet.real.xyz")?;
89///
90/// // Read the engine's health snapshot: heights and unix-ms timestamps
91/// let health = client.healthcheck().await?;
92///
93/// // Get the service version
94/// let version = client.get_version().await?;
95/// # Ok(())
96/// # }
97/// ```
98#[derive(Clone, Debug)]
99pub struct Client {
100    http_client: HttpClient,
101    // the chain id belongs to the endpoint, not to any one signer, so the cache
102    // lives here: every clone — and every `SigningClient` built on them — shares
103    // one lookup.
104    chain_id: Arc<OnceCell<String>>,
105}
106
107impl Client {
108    /// Creates a new client instance for the Real Markets matching engine.
109    ///
110    /// This method establishes a connection to the matching engine's JSON-RPC API endpoint.
111    /// The client uses HTTP transport and handles connection pooling and request/response
112    /// serialization automatically.
113    ///
114    /// # Arguments
115    ///
116    /// * `url` - The base URL of the matching engine API (e.g., "<https://matching.api.testnet.real.xyz>")
117    ///
118    /// # Returns
119    ///
120    /// * `Ok(Client)` - Successfully created client instance
121    /// * `Err(_)` - Failed to create HTTP client (usually due to invalid URL format)
122    ///
123    /// # Examples
124    ///
125    /// ```rust,no_run
126    /// use sdk::Client;
127    ///
128    /// # fn example() -> anyhow::Result<()> {
129    /// // Create a client for testnet
130    /// let client = Client::new("https://matching.api.testnet.real.xyz")?;
131    ///
132    /// // Create a client for local development
133    /// let local_client = Client::new("http://localhost:1789")?;
134    /// # Ok(())
135    /// # }
136    /// ```
137    ///
138    /// # Errors
139    ///
140    /// This method will return an error if:
141    /// - The URL format is invalid
142    /// - The HTTP client cannot be initialized
143    pub fn new(url: impl AsRef<str>) -> anyhow::Result<Client> {
144        let http_client = HttpClient::builder()
145            .build(url)
146            .context("couldn't instantiate http client for sdk client")?;
147
148        Ok(Client {
149            http_client,
150            chain_id: Arc::new(OnceCell::new()),
151        })
152    }
153
154    /// Sends a signed transaction to the matching engine for processing.
155    ///
156    /// This is the primary method for submitting orders, cancellations, and other operations
157    /// to the matching engine. The transaction must be properly signed before submission.
158    ///
159    /// # Arguments
160    ///
161    /// * `tx` - A signed `Transaction` containing one or more commands (orders, cancellations, etc.)
162    ///
163    /// # Returns
164    ///
165    /// * `Ok(TransactionReport)` - The engine returned a verdict. That includes
166    ///   rejection: an admission failure (bad signature, stale or reused nonce,
167    ///   chain-id mismatch, rate limit, per-command validation) arrives from the
168    ///   node as a JSON-RPC error carrying a `TransactionReport` in its `data`,
169    ///   and this method unwraps it into `Ok`. Always check `report.success` —
170    ///   an `Ok` here is not an acceptance.
171    /// * `Err(_)` - No verdict at all: transport failure, or a node-level error
172    ///   with no report attached (sequencer not ready, ingress disabled, internal
173    ///   error, or an admitted transaction whose outcome execution never reported)
174    ///
175    /// # Examples
176    ///
177    /// ```rust,no_run
178    /// use sdk::{
179    ///     Client,
180    ///     crypto::Signer,
181    ///     types::{SubmitOrderRequest, Transaction, TransactionPayload},
182    /// };
183    ///
184    /// # async fn example() -> anyhow::Result<()> {
185    /// let client = Client::new("https://matching.api.testnet.real.xyz")?;
186    /// let signer: Signer =
187    ///     "iotaprivkey1qrsd2u9ztqr6z2y78sp2ch8kcyak9cx3ezphkpp4acqaqruajxr6qye2645".into();
188    ///
189    /// // `chain_id` must be the one the endpoint reports (`real-t-0001` on
190    /// // testnet) and `nonce` microseconds since the Unix epoch — admission
191    /// // rejects a mismatched chain id and a nonce outside its replay window
192    /// let payload = TransactionPayload::builder()
193    ///     .nonce(1_757_000_000_000_000)
194    ///     .chain_id("real-t-0001")
195    ///     .command(
196    ///         SubmitOrderRequest::builder()
197    ///             .market("0x5efc5ad00c209baf4c5d77dfd1be18e8b50916e53adbe2142ae1b47af445303c")
198    ///             .account("0xcc6ebf909253ea35314bb28d91321f6415827c01790fba84aaf570c87372850a")
199    ///             .buy()
200    ///             .limit_order("50000.00")
201    ///             .quantity("0.1")
202    ///             .good_till_cancel()
203    ///             .build()?,
204    ///     )
205    ///     .build()?;
206    ///
207    /// let transaction = Transaction::builder()
208    ///     .client_request_id("unique-id")
209    ///     .payload(payload)
210    ///     .sign_and_build(signer)?;
211    ///
212    /// // a rejected transaction also arrives as `Ok`; check `report.success`
213    /// let report = client.send_transaction(transaction).await?;
214    /// println!("Transaction result: {:?}", report);
215    /// # Ok(())
216    /// # }
217    /// ```
218    ///
219    /// # Errors
220    ///
221    /// This method will return an error if:
222    /// - Network connection fails
223    /// - Request times out
224    /// - Response cannot be parsed
225    /// - The node answered with an error that carried no `TransactionReport`
226    ///
227    /// A transaction the engine *rejects* is not an error here: it comes back as
228    /// `Ok(report)` with `report.success == false`.
229    pub async fn send_transaction(&self, tx: Transaction) -> anyhow::Result<TransactionReport> {
230        match self
231            .http_client
232            .request::<TransactionReport, _>("real_rawTransaction", rpc_params![tx])
233            .await
234        {
235            Ok(report) => return Ok(report),
236            Err(ClientError::Call(err)) => {
237                // server returns validation errors as JSON-RPC errors with
238                // TransactionReport in the data field — extract it
239                if let Some(raw) = err.data()
240                    && let Ok(report) = json::from_str::<TransactionReport>(raw.get())
241                {
242                    return Ok(report);
243                }
244                return Err(anyhow::anyhow!("rpc call error: {}", err));
245            }
246            Err(e) => return Err(e).context("http client errored while sending transaction"),
247        }
248    }
249
250    /// Retrieves the version information of the matching engine.
251    ///
252    /// This method queries the matching engine to get its current version string,
253    /// which is useful for compatibility checking and debugging.
254    ///
255    /// # Returns
256    ///
257    /// * `Ok(String)` - The version string of the matching engine
258    /// * `Err(_)` - Network error or failed to parse response
259    ///
260    /// # Examples
261    ///
262    /// ```rust,no_run
263    /// use sdk::Client;
264    ///
265    /// # async fn example() -> anyhow::Result<()> {
266    /// let client = Client::new("https://matching.api.testnet.real.xyz")?;
267    /// let version = client.get_version().await?;
268    /// println!("Matching engine version: {}", version);
269    /// # Ok(())
270    /// # }
271    /// ```
272    ///
273    /// # Errors
274    ///
275    /// This method will return an error if:
276    /// - Network connection fails
277    /// - The matching engine is unreachable
278    /// - Response format is unexpected
279    /// - Request times out
280    pub async fn get_version(&self) -> anyhow::Result<String> {
281        let version = self
282            .http_client
283            .request("real_clientVersion", rpc_params![])
284            .await
285            .context("http client errored")?;
286
287        Ok(version)
288    }
289
290    /// Retrieves the `chain_id` this endpoint expects in signed transaction payloads.
291    ///
292    /// The value is fixed for the life of a network, so it is fetched from the node
293    /// once on the first call and cached for the life of the client. The cache is
294    /// shared by every clone of this client, so many [`crate::SigningClient`]s built
295    /// on one `Client` cost a single round-trip between them.
296    ///
297    /// Prefer letting [`crate::SigningClient`] resolve this for you — build it
298    /// without a `chain_id` and it will call this on first submission.
299    ///
300    /// # Examples
301    ///
302    /// ```rust,no_run
303    /// use sdk::Client;
304    ///
305    /// # async fn example() -> anyhow::Result<()> {
306    /// let client = Client::new("https://matching.api.testnet.real.xyz")?;
307    /// let chain_id = client.get_chain_id().await?;
308    /// println!("signing against: {}", chain_id);
309    /// # Ok(())
310    /// # }
311    /// ```
312    ///
313    /// # Errors
314    ///
315    /// This method will return an error if the node is unreachable or the response
316    /// cannot be parsed. A failed lookup is not cached — the next call retries.
317    pub async fn get_chain_id(&self) -> anyhow::Result<&str> {
318        let chain_id = self
319            .chain_id
320            .get_or_try_init(|| async {
321                return self
322                    .http_client
323                    .request::<String, _>("real_chainId", rpc_params![])
324                    .await
325                    .context("failed to fetch chain id from the matching engine");
326            })
327            .await?;
328
329        return Ok(&**chain_id);
330    }
331
332    /// Asserts this endpoint is the network `expected` names.
333    ///
334    /// Pointing a client at the wrong URL is otherwise silent: transactions get
335    /// signed for whatever network the caller believes it is on. Call this once
336    /// at startup to turn that into a boot failure.
337    ///
338    /// # Errors
339    ///
340    /// Fails if the endpoint reports a different chain id, or is unreachable.
341    pub async fn verify_chain_id(&self, expected: &str) -> anyhow::Result<()> {
342        let reported = self.get_chain_id().await?;
343        if reported != expected {
344            anyhow::bail!(
345                "chain id mismatch: expected {expected}, endpoint reports {reported} — \
346                 check the endpoint url and the chain_id in your config"
347            );
348        }
349
350        return Ok(());
351    }
352
353    /// Performs a health check on the matching engine.
354    ///
355    /// This method pings the matching engine to verify it's running and responsive.
356    /// It's useful for monitoring, load balancer health checks, and ensuring
357    /// connectivity before attempting to send transactions.
358    ///
359    /// # Returns
360    ///
361    /// * `Ok(HealthCheck)` - The node answered. `status` is a constant `true` on
362    ///   any node that answers at all, so the liveness signal is the other four
363    ///   fields: `sequencer_height` / `sequencer_ts` (the last block the engine
364    ///   opened and its unix-millisecond timestamp) and `settlement_chain_height`
365    ///   / `settlement_chain_ts` (the last settlement-chain checkpoint ingested).
366    ///   A node that has not produced a block yet reports zeros.
367    /// * `Err(_)` - Network error or failed to reach the matching engine
368    ///
369    /// # Examples
370    ///
371    /// ```rust,no_run
372    /// use sdk::Client;
373    ///
374    /// # async fn example() -> anyhow::Result<()> {
375    /// let client = Client::new("https://matching.api.testnet.real.xyz")?;
376    ///
377    /// // Check health before sending important transactions
378    /// let health = client.healthcheck().await?;
379    /// println!(
380    ///     "sequencer {}@{} ms, settlement chain {}@{} ms",
381    ///     health.sequencer_height,
382    ///     health.sequencer_ts,
383    ///     health.settlement_chain_height,
384    ///     health.settlement_chain_ts,
385    /// );
386    /// # Ok(())
387    /// # }
388    /// ```
389    ///
390    /// # Use Cases
391    ///
392    /// - **Monitoring**: Regular health checks for system monitoring
393    /// - **Load Balancing**: Health checks for load balancer configuration
394    /// - **Pre-flight Checks**: Verify connectivity before critical operations
395    /// - **Circuit Breaker**: Implement circuit breaker patterns in client code
396    ///
397    /// # Errors
398    ///
399    /// This method will return an error if:
400    /// - Network connection fails
401    /// - The matching engine is completely unreachable
402    /// - Response cannot be parsed
403    /// - Request times out
404    pub async fn healthcheck(&self) -> anyhow::Result<HealthCheck> {
405        let result: HealthCheck = self
406            .http_client
407            .request("real_healthCheck", rpc_params![])
408            .await
409            .context("http client errored")?;
410
411        return Ok(result);
412    }
413}