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}