sdk/signing_client/mod.rs
1//! High-level signing client for the Real Markets matching engine.
2//!
3//! This module provides the [`SigningClient`] struct, which is a convenience wrapper
4//! around the low-level [`Client`] that automatically handles transaction signing and
5//! provides simplified methods for common operations like submitting and canceling orders.
6//!
7//! The signing client manages cryptographic operations transparently, automatically
8//! generating nonces, signing transactions, and handling the submission workflow.
9//!
10//! # Key Features
11//!
12//! - **Automatic Signing**: All transactions are signed automatically using the configured signer
13//! - **Simplified API**: High-level methods for common operations (submit/cancel orders)
14//! - **Network Presets**: Built-in configurations for testnet and mainnet
15//! - **Batch Operations**: Support for submitting multiple commands in a single transaction
16//! - **Nonce Management**: Automatic nonce generation using system time
17//!
18//! # Network Configuration
19//!
20//! - **Testnet**: [`SigningClient::new_testnet`] for testing and development
21//! - **Mainnet**: [`SigningClient::new_mainnet`] for production trading
22//! - **Custom**: [`SigningClient::new`] for custom network configurations
23//! - **Discovered**: [`SigningClient::builder`] with no `chain_id`, to read it
24//! from the endpoint itself
25//!
26//! # Examples
27//!
28//! ## Basic Usage
29//! ```rust,no_run
30//! use sdk::{SigningClient, crypto::Signer, types::SubmitOrderRequest};
31//!
32//! #[tokio::main]
33//! async fn main() -> anyhow::Result<()> {
34//! // a private key in IOTA bech32 form (`iotaprivkey1…`), not hex
35//! let signer: Signer =
36//! "iotaprivkey1qrsd2u9ztqr6z2y78sp2ch8kcyak9cx3ezphkpp4acqaqruajxr6qye2645".into();
37//!
38//! // Create a testnet client
39//! let client = SigningClient::new_testnet(signer)?;
40//!
41//! // `market` and `account` are `0x`-prefixed on-chain object ids
42//! let order = SubmitOrderRequest::builder()
43//! .market("0x5efc5ad00c209baf4c5d77dfd1be18e8b50916e53adbe2142ae1b47af445303c")
44//! .account("0xcc6ebf909253ea35314bb28d91321f6415827c01790fba84aaf570c87372850a")
45//! .buy()
46//! .limit_order("50000.00")
47//! .quantity("0.1")
48//! .good_till_cancel()
49//! .build()?;
50//!
51//! let report = client.submit_order(order, None).await?;
52//! println!("Order submitted: {:?}", report);
53//!
54//! Ok(())
55//! }
56//! ```
57
58use std::{
59 sync::{Arc, Mutex},
60 time::{SystemTime, UNIX_EPOCH},
61};
62
63pub use builder::SigningClientBuilder;
64use networks::Network;
65
66use crate::{
67 client::Client,
68 crypto::Signer,
69 types::{
70 CancelOrderRequest, Command, LiquidatePositionRequest, SetImrRequest, SubmitOrderRequest,
71 SwitchToCrossRequest, SwitchToIsolatedRequest, Transaction, TransactionPayload,
72 TransactionReport,
73 },
74};
75
76mod builder;
77
78/// High-level client that automatically handles transaction signing.
79///
80/// `SigningClient` wraps the low-level [`Client`] and provides a simplified interface
81/// for interacting with the Real Markets matching engine. It automatically handles:
82///
83/// - Transaction signing using the configured [`Signer`]
84/// - Nonce generation for transactions
85/// - Common operations like submitting and canceling orders
86/// - Batch transaction submission
87///
88/// This is the recommended client for most use cases as it reduces boilerplate
89/// and handles cryptographic operations automatically.
90///
91/// # Examples
92///
93/// ```rust,no_run
94/// use sdk::{SigningClient, crypto::Signer};
95///
96/// # fn example() -> anyhow::Result<()> {
97/// // the key is an IOTA bech32 private key (`iotaprivkey1…`), not hex
98/// let signer: Signer =
99/// "iotaprivkey1qrsd2u9ztqr6z2y78sp2ch8kcyak9cx3ezphkpp4acqaqruajxr6qye2645".into();
100/// let client = SigningClient::new_testnet(signer)?;
101///
102/// // Now ready to submit orders, cancel orders, etc.
103/// # Ok(())
104/// # }
105/// ```
106#[derive(Debug, Clone)]
107pub struct SigningClient {
108 http_client: Client,
109 signer: Signer,
110 // `None` means "ask the endpoint" — resolved and cached by the underlying
111 // `Client` on first use. see [`SigningClient::chain_id`].
112 chain_id: Option<String>,
113 nonce: Arc<Mutex<u64>>,
114}
115
116impl SigningClient {
117 /// Creates a new signing client with custom network configuration.
118 ///
119 /// This method allows you to connect to custom or development instances
120 /// of the matching engine by specifying the URL and chain ID manually.
121 ///
122 /// # Arguments
123 ///
124 /// * `signer` - Cryptographic signer for transaction signing
125 /// * `url` - Base URL of the matching engine API endpoint
126 /// * `chain_id` - Chain ID to use for transaction signing (must match the target network)
127 ///
128 /// # Returns
129 ///
130 /// * `Ok(SigningClient)` - Successfully created signing client
131 /// * `Err(_)` - Failed to create the underlying HTTP client
132 ///
133 /// # Examples
134 ///
135 /// ```rust,no_run
136 /// use sdk::{SigningClient, crypto::Signer};
137 ///
138 /// # fn example() -> anyhow::Result<()> {
139 /// let signer: Signer =
140 /// "iotaprivkey1qrsd2u9ztqr6z2y78sp2ch8kcyak9cx3ezphkpp4acqaqruajxr6qye2645".into();
141 ///
142 /// // Connect to a self-hosted node. The chain id must be the one that node
143 /// // reports over `real_chainId` — `real-t-0001` for a node following
144 /// // testnet — or every transaction is rejected for chain-id mismatch. Leave
145 /// // it unset (`SigningClient::builder`) to have it read from the endpoint.
146 /// let client = SigningClient::new(signer, "http://localhost:1789", "real-t-0001")?;
147 /// # Ok(())
148 /// # }
149 /// ```
150 ///
151 /// # Errors
152 ///
153 /// This method will return an error if the underlying HTTP client cannot be created,
154 /// typically due to invalid URL format.
155 pub fn new(
156 signer: Signer,
157 url: impl AsRef<str>,
158 chain_id: impl ToString,
159 ) -> anyhow::Result<SigningClient> {
160 return Self::builder(signer)
161 .url(url.as_ref())
162 .chain_id(chain_id.to_string())
163 .build();
164 }
165
166 /// Starts building a signing client whose chain id may be left unset, to be
167 /// read from the endpoint instead. See [`SigningClientBuilder`].
168 ///
169 /// ```rust,no_run
170 /// use sdk::{SigningClient, crypto::Signer};
171 ///
172 /// # fn example(signer: Signer) -> anyhow::Result<()> {
173 /// let client = SigningClient::builder(signer)
174 /// .url("http://localhost:1789")
175 /// .build()?;
176 /// # Ok(())
177 /// # }
178 /// ```
179 pub fn builder(signer: Signer) -> SigningClientBuilder {
180 return SigningClientBuilder::new(signer);
181 }
182
183 /// creates a signing client reusing an existing `Client` (and thus its underlying HTTP
184 /// connection pool, and its chain-id cache). use this when building many signing clients
185 /// for different signers against the same endpoint — avoids DNS/connection storms.
186 pub fn from_client(
187 http_client: Client,
188 signer: Signer,
189 chain_id: impl ToString,
190 ) -> SigningClient {
191 return builder::assemble(http_client, signer, Some(chain_id.to_string()));
192 }
193
194 /// Creates a new signing client configured for the Real Markets testnet.
195 ///
196 /// This is a convenience method that pre-configures the client with the
197 /// official testnet URL and chain ID. Use this for testing and development.
198 ///
199 /// # Arguments
200 ///
201 /// * `signer` - Cryptographic signer for transaction signing
202 ///
203 /// # Returns
204 ///
205 /// * `Ok(SigningClient)` - Successfully created testnet signing client
206 /// * `Err(_)` - Failed to create the underlying HTTP client
207 ///
208 /// # Examples
209 ///
210 /// ```rust,no_run
211 /// use sdk::{SigningClient, crypto::Signer};
212 ///
213 /// # fn example() -> anyhow::Result<()> {
214 /// let signer: Signer =
215 /// "iotaprivkey1qrsd2u9ztqr6z2y78sp2ch8kcyak9cx3ezphkpp4acqaqruajxr6qye2645".into();
216 /// let client = SigningClient::new_testnet(signer)?;
217 ///
218 /// // Ready to trade on testnet: https://matching.api.testnet.real.xyz,
219 /// // chain id `real-t-0001`
220 /// # Ok(())
221 /// # }
222 /// ```
223 pub fn new_testnet(signer: Signer) -> anyhow::Result<SigningClient> {
224 return Self::builder(signer)
225 .url(Network::Testnet.matching_url())
226 .chain_id(Network::Testnet.chain_id())
227 .build();
228 }
229
230 /// Creates a new signing client configured for the Real Markets mainnet.
231 ///
232 /// This is a convenience method that pre-configures the client with the
233 /// official mainnet URL and chain ID. Use this for production trading.
234 ///
235 /// **Warning**: This connects to the production network where real value
236 /// is at stake. Ensure your private keys are secure and your code is thoroughly tested.
237 ///
238 /// # Arguments
239 ///
240 /// * `signer` - Cryptographic signer for transaction signing
241 ///
242 /// # Returns
243 ///
244 /// * `Ok(SigningClient)` - Successfully created mainnet signing client
245 /// * `Err(_)` - Failed to create the underlying HTTP client
246 ///
247 /// # Examples
248 ///
249 /// ```rust,no_run
250 /// use sdk::{SigningClient, crypto::Signer};
251 ///
252 /// # fn example() -> anyhow::Result<()> {
253 /// let signer: Signer =
254 /// "iotaprivkey1qrsd2u9ztqr6z2y78sp2ch8kcyak9cx3ezphkpp4acqaqruajxr6qye2645".into();
255 /// let client = SigningClient::new_mainnet(signer)?;
256 ///
257 /// // Ready for production trading: https://sequencer.api.real.xyz,
258 /// // chain id `real-0001`
259 /// # Ok(())
260 /// # }
261 /// ```
262 pub fn new_mainnet(signer: Signer) -> anyhow::Result<SigningClient> {
263 return Self::builder(signer)
264 .url(Network::Mainnet.matching_url())
265 .chain_id(Network::Mainnet.chain_id())
266 .build();
267 }
268
269 /// Submits a single order to the matching engine.
270 ///
271 /// This is a convenience method that creates a transaction containing a single
272 /// submit order command, signs it, and sends it to the matching engine.
273 ///
274 /// # Arguments
275 ///
276 /// * `order` - The order details to submit (market, price, quantity, etc.)
277 /// * `client_request_id` - Optional unique identifier for tracking this request
278 ///
279 /// # Returns
280 ///
281 /// * `Ok(TransactionReport)` - The engine returned a verdict — which includes
282 /// rejection. Check `report.success` and the order receipt.
283 /// * `Err(_)` - Signing failed, or no verdict came back at all (transport
284 /// failure, or a node error carrying no report)
285 ///
286 /// # Examples
287 ///
288 /// ```rust,no_run
289 /// use sdk::{SigningClient, types::SubmitOrderRequest};
290 ///
291 /// # async fn example(client: SigningClient) -> anyhow::Result<()> {
292 /// // Submit a market buy order. `market` and `account` are `0x`-prefixed
293 /// // on-chain object ids — a ticker symbol fails in `build()`.
294 /// let market_order = SubmitOrderRequest::builder()
295 /// .market("0x5efc5ad00c209baf4c5d77dfd1be18e8b50916e53adbe2142ae1b47af445303c")
296 /// .account("0xcc6ebf909253ea35314bb28d91321f6415827c01790fba84aaf570c87372850a")
297 /// .buy()
298 /// .market_order()
299 /// .quantity("0.1")
300 /// .immediate_or_cancel()
301 /// .build()?;
302 ///
303 /// let report = client
304 /// .submit_order(market_order, Some("req-001".to_string()))
305 /// .await?;
306 /// println!("Market order result: {:?}", report);
307 ///
308 /// // Submit a limit sell order
309 /// let limit_order = SubmitOrderRequest::builder()
310 /// .market("0x0f4e0dcd1c3ba1e3f9c25b0e7f9e7d0e6c1a8b3d2e4f5061728394a5b6c7d8e9")
311 /// .account("0xcc6ebf909253ea35314bb28d91321f6415827c01790fba84aaf570c87372850a")
312 /// .sell()
313 /// .limit_order("3000.00")
314 /// .quantity("1.0")
315 /// .good_till_cancel()
316 /// .build()?;
317 ///
318 /// let report = client.submit_order(limit_order, None).await?;
319 /// println!("Limit order result: {:?}", report);
320 /// # Ok(())
321 /// # }
322 /// ```
323 ///
324 /// # Errors
325 ///
326 /// This method will return an error if:
327 /// - Transaction signing fails
328 /// - Network connection fails
329 /// - The node answered with an error that carried no report
330 ///
331 /// An order the engine *rejects* — failed validation, an unknown market,
332 /// insufficient margin — is not an error: it comes back as `Ok(report)`,
333 /// with the reason in `report.errors` or in the order receipt.
334 pub async fn submit_order(
335 &self,
336 order: SubmitOrderRequest,
337 client_request_id: Option<String>,
338 ) -> anyhow::Result<TransactionReport> {
339 return self
340 .submit_batch([Command::SubmitOrder(order)], client_request_id)
341 .await;
342 }
343
344 /// Cancels a single order in the matching engine.
345 ///
346 /// This method creates a transaction containing a single cancel order command,
347 /// signs it, and sends it to the matching engine for processing.
348 ///
349 /// # Arguments
350 ///
351 /// * `order` - The cancellation request (order ID, account, market, etc.)
352 /// * `client_request_id` - Optional unique identifier for tracking this request
353 ///
354 /// # Returns
355 ///
356 /// * `Ok(TransactionReport)` - The engine returned a verdict — which includes
357 /// rejection, and also the no-op case where nothing matched the request.
358 /// Read the cancel receipt to see what was actually terminated.
359 /// * `Err(_)` - Signing failed, or no verdict came back at all
360 ///
361 /// # Examples
362 ///
363 /// ```rust,no_run
364 /// use sdk::{SigningClient, types::CancelOrderRequest};
365 ///
366 /// # async fn example(client: SigningClient) -> anyhow::Result<()> {
367 /// // Cancel one order by its engine order id — a `u128`, not a string.
368 /// // `client_order_id` is the alternative, for ids you minted yourself.
369 /// let cancel_specific = CancelOrderRequest::builder()
370 /// .market("0x5efc5ad00c209baf4c5d77dfd1be18e8b50916e53adbe2142ae1b47af445303c")
371 /// .account("0xcc6ebf909253ea35314bb28d91321f6415827c01790fba84aaf570c87372850a")
372 /// .order_id(123456789)
373 /// .build()?;
374 ///
375 /// let report = client.cancel_order(cancel_specific, None).await?;
376 /// println!("Cancel result: {:?}", report);
377 ///
378 /// // Setting neither id cancels every order the account holds in that market
379 /// let cancel_all = CancelOrderRequest::builder()
380 /// .market("0x5efc5ad00c209baf4c5d77dfd1be18e8b50916e53adbe2142ae1b47af445303c")
381 /// .account("0xcc6ebf909253ea35314bb28d91321f6415827c01790fba84aaf570c87372850a")
382 /// .build()?;
383 ///
384 /// let report = client
385 /// .cancel_order(cancel_all, Some("cancel-all-001".to_string()))
386 /// .await?;
387 /// println!("Cancel all result: {:?}", report);
388 /// # Ok(())
389 /// # }
390 /// ```
391 ///
392 /// # Errors
393 ///
394 /// This method will return an error if:
395 /// - Transaction signing fails
396 /// - Network connection fails
397 /// - The node answered with an error that carried no report
398 ///
399 /// Cancelling is best-effort at the engine: an order id that does not exist,
400 /// is already filled, or belongs to someone else is logged and skipped, and
401 /// the call still returns `Ok` with that id absent from the cancel receipt.
402 pub async fn cancel_order(
403 &self,
404 order: CancelOrderRequest,
405 client_request_id: Option<String>,
406 ) -> anyhow::Result<TransactionReport> {
407 return self
408 .submit_batch([Command::CancelOrder(order)], client_request_id)
409 .await;
410 }
411
412 /// Asks the matching engine to liquidate a distressed account.
413 ///
414 /// This method creates a transaction containing a single liquidate position command,
415 /// signs it, and sends it to the matching engine for processing.
416 ///
417 /// The request names no market. The engine assesses every market the distressed
418 /// account has a position or orders in and liquidates the ones that breach
419 /// maintenance margin, so this is a trigger rather than an instruction: an
420 /// account that is solvent, has nothing open, or holds a position in a market
421 /// whose mark price is stale is left alone and the call still succeeds.
422 ///
423 /// # Arguments
424 ///
425 /// * `liquidation` - `account` is the caller triggering the liquidation and
426 /// `distressed_account` the account to be liquidated; both are required
427 /// * `client_request_id` - Optional unique identifier for tracking this request
428 ///
429 /// # Returns
430 ///
431 /// * `Ok(TransactionReport)` - The engine returned a verdict — which includes
432 /// both rejection and "there was nothing to liquidate"
433 /// * `Err(_)` - Signing failed, or no verdict came back at all
434 ///
435 /// # Examples
436 ///
437 /// ```rust,no_run
438 /// use sdk::{SigningClient, types::LiquidatePositionRequest};
439 ///
440 /// # async fn example(client: SigningClient) -> anyhow::Result<()> {
441 /// // `account` is the liquidator, `distressed_account` the account being
442 /// // liquidated. Both are required and there is no market to name.
443 /// let liquidation = LiquidatePositionRequest::builder()
444 /// .account("0xcc6ebf909253ea35314bb28d91321f6415827c01790fba84aaf570c87372850a")
445 /// .distressed_account(
446 /// "0x0000000000000000000000000000000000000000000000000000000000000002",
447 /// )
448 /// .build()?;
449 ///
450 /// let report = client
451 /// .liquidate_position(liquidation, Some("liq-001".to_string()))
452 /// .await?;
453 /// println!("Liquidation result: {:?}", report);
454 /// # Ok(())
455 /// # }
456 /// ```
457 ///
458 /// # Errors
459 ///
460 /// This method will return an error if:
461 /// - Transaction signing fails
462 /// - Network connection fails
463 /// - The node answered with an error that carried no report
464 ///
465 /// Neither a rejected request (an empty `account` or `distressed_account`)
466 /// nor a liquidation that found nothing to do is an error here: the first
467 /// comes back as `Ok(report)` with `report.success == false`, the second as
468 /// an empty `LiquidationReport`.
469 pub async fn liquidate_position(
470 &self,
471 liquidation: LiquidatePositionRequest,
472 client_request_id: Option<String>,
473 ) -> anyhow::Result<TransactionReport> {
474 return self
475 .submit_batch([Command::LiquidatePosition(liquidation)], client_request_id)
476 .await;
477 }
478
479 /// Switches a position to cross-margin mode.
480 ///
481 /// Margin-parameter changes are sequencer-only and must be transmitted as
482 /// a transaction containing exactly one command. The matching engine
483 /// validates and applies state immediately, then relays the change to the
484 /// chain. Subsequent margin-parameter changes for the same account on any
485 /// market are rejected until chain confirmation.
486 pub async fn switch_to_cross(
487 &self,
488 request: SwitchToCrossRequest,
489 client_request_id: Option<String>,
490 ) -> anyhow::Result<TransactionReport> {
491 return self
492 .submit_batch([Command::SwitchToCross(request)], client_request_id)
493 .await;
494 }
495
496 /// Switches a position to isolated-margin mode. See `switch_to_cross` for batching rules.
497 pub async fn switch_to_isolated(
498 &self,
499 request: SwitchToIsolatedRequest,
500 client_request_id: Option<String>,
501 ) -> anyhow::Result<TransactionReport> {
502 return self
503 .submit_batch([Command::SwitchToIsolated(request)], client_request_id)
504 .await;
505 }
506
507 /// Sets the position-level IMR for an account/market. See `switch_to_cross` for batching rules.
508 pub async fn set_imr(
509 &self,
510 request: SetImrRequest,
511 client_request_id: Option<String>,
512 ) -> anyhow::Result<TransactionReport> {
513 return self
514 .submit_batch([Command::SetImr(request)], client_request_id)
515 .await;
516 }
517
518 /// Submits a batch of commands in a single transaction.
519 ///
520 /// This method allows you to group multiple operations (orders, cancellations, etc.)
521 /// into a single transaction, which is more efficient than sending individual
522 /// transactions and ensures atomic execution of all commands.
523 ///
524 /// The method automatically generates a nonce using the current system time,
525 /// signs the transaction, and submits it to the matching engine.
526 ///
527 /// # Arguments
528 ///
529 /// * `commands` - An iterable collection of commands to execute
530 /// * `client_request_id` - Optional unique identifier for tracking this transaction
531 ///
532 /// # Returns
533 ///
534 /// * `Ok(TransactionReport)` - The engine returned a verdict for the batch,
535 /// accepted or rejected; `report.receipts` holds one receipt per command
536 /// that ran and `report.errors` is keyed by command index
537 /// * `Err(_)` - Signing failed, or no verdict came back at all
538 ///
539 /// # Examples
540 ///
541 /// ```rust,no_run
542 /// use sdk::{
543 /// SigningClient,
544 /// types::{CancelOrderRequest, Command, SubmitOrderRequest},
545 /// };
546 ///
547 /// # async fn example(client: SigningClient) -> anyhow::Result<()> {
548 /// // Quote both sides of one market in a single transaction
549 /// let commands = vec![
550 /// Command::SubmitOrder(
551 /// SubmitOrderRequest::builder()
552 /// .market("0x5efc5ad00c209baf4c5d77dfd1be18e8b50916e53adbe2142ae1b47af445303c")
553 /// .account("0xcc6ebf909253ea35314bb28d91321f6415827c01790fba84aaf570c87372850a")
554 /// .buy()
555 /// .limit_order("49000.00")
556 /// .quantity("0.1")
557 /// .good_till_cancel()
558 /// .build()?,
559 /// ),
560 /// Command::SubmitOrder(
561 /// SubmitOrderRequest::builder()
562 /// .market("0x5efc5ad00c209baf4c5d77dfd1be18e8b50916e53adbe2142ae1b47af445303c")
563 /// .account("0xcc6ebf909253ea35314bb28d91321f6415827c01790fba84aaf570c87372850a")
564 /// .sell()
565 /// .limit_order("51000.00")
566 /// .quantity("0.1")
567 /// .good_till_cancel()
568 /// .build()?,
569 /// ),
570 /// ];
571 ///
572 /// let report = client
573 /// .submit_batch(commands, Some("batch-001".to_string()))
574 /// .await?;
575 /// println!("Batch result: {:?}", report);
576 ///
577 /// // Cancel every resting order in the market, then take the offer.
578 /// // Omitting both ids on the cancel is what makes it a cancel-all.
579 /// let commands = vec![
580 /// Command::CancelOrder(
581 /// CancelOrderRequest::builder()
582 /// .market("0x5efc5ad00c209baf4c5d77dfd1be18e8b50916e53adbe2142ae1b47af445303c")
583 /// .account("0xcc6ebf909253ea35314bb28d91321f6415827c01790fba84aaf570c87372850a")
584 /// .build()?,
585 /// ),
586 /// Command::SubmitOrder(
587 /// SubmitOrderRequest::builder()
588 /// .market("0x5efc5ad00c209baf4c5d77dfd1be18e8b50916e53adbe2142ae1b47af445303c")
589 /// .account("0xcc6ebf909253ea35314bb28d91321f6415827c01790fba84aaf570c87372850a")
590 /// .buy()
591 /// .market_order()
592 /// .quantity("1.0")
593 /// .immediate_or_cancel()
594 /// .build()?,
595 /// ),
596 /// ];
597 ///
598 /// let report = client.submit_batch(commands, None).await?;
599 /// # Ok(())
600 /// # }
601 /// ```
602 ///
603 /// # Atomicity
604 ///
605 /// Admission is all-or-nothing: if any command fails validation the whole
606 /// transaction is rejected and nothing runs. Execution is not. The engine
607 /// walks the batch in order and each command yields its own receipt, so a
608 /// cancel that succeeded stays cancelled even if the order after it is
609 /// rejected. There is no rollback — read every receipt, not just
610 /// `report.success`.
611 ///
612 /// One exception to batching at all: the three margin-parameter commands
613 /// (`switch_to_cross`, `switch_to_isolated`, `set_imr`) must be alone in a
614 /// transaction, and a batch containing one alongside anything else is
615 /// rejected outright.
616 ///
617 /// # Nonce Management
618 ///
619 /// The method automatically generates a unique nonce using microseconds since
620 /// Unix epoch. This ensures transaction uniqueness and prevents replay attacks.
621 ///
622 /// # Errors
623 ///
624 /// This method will return an error if:
625 /// - Transaction signing fails
626 /// - Network connection fails
627 /// - The node answered with an error that carried no report
628 /// - The chain id has to be discovered and the endpoint cannot be reached
629 ///
630 /// A batch the engine *rejects* — a command that fails validation, or a
631 /// margin-parameter command batched with others — is not an error: it comes
632 /// back as `Ok(report)` with `report.errors` keyed by command index. A
633 /// system clock before the Unix epoch panics rather than erroring.
634 pub async fn submit_batch<I>(
635 &self,
636 commands: I,
637 client_request_id: Option<String>,
638 ) -> anyhow::Result<TransactionReport>
639 where
640 I: IntoIterator<Item = Command>,
641 {
642 let payload = TransactionPayload::builder()
643 .nonce(self.next_nonce())
644 .chain_id(self.chain_id().await?)
645 .commands(commands)
646 .build()?;
647
648 let mut transaction_builder = Transaction::builder().payload(payload);
649 if let Some(cid) = client_request_id {
650 transaction_builder = transaction_builder.client_request_id(cid);
651 }
652 let transaction = transaction_builder.sign_and_build(self.signer.clone())?;
653
654 return self.http_client.send_transaction(transaction).await;
655 }
656
657 /// The chain id this client signs with.
658 ///
659 /// If one was supplied at construction it is returned as-is. Otherwise it is
660 /// read from the endpoint and cached on the underlying [`Client`], so only
661 /// the first call costs a round-trip. Every transaction resolves its chain id
662 /// through here.
663 ///
664 /// ```rust,no_run
665 /// use sdk::{SigningClient, crypto::Signer};
666 ///
667 /// # async fn example(signer: Signer, url: &str) -> anyhow::Result<()> {
668 /// let client = SigningClient::builder(signer).url(url).build()?;
669 /// println!("trading on {}", client.chain_id().await?);
670 /// # Ok(())
671 /// # }
672 /// ```
673 ///
674 /// # Errors
675 ///
676 /// Returns an error only in the discovered case, if the endpoint is
677 /// unreachable or its response cannot be parsed.
678 pub async fn chain_id(&self) -> anyhow::Result<&str> {
679 return match &self.chain_id {
680 Some(chain_id) => Ok(&**chain_id),
681 None => self.http_client.get_chain_id().await,
682 };
683 }
684
685 /// Asserts that the endpoint is the network this client was configured for.
686 ///
687 /// Call this once at startup. A client built with an explicit chain id will
688 /// otherwise sign for whatever network its configured id names, even when
689 /// pointed at a different endpoint entirely — so a wrong URL silently
690 /// produces transactions for the wrong network. This turns that into a boot
691 /// failure.
692 ///
693 /// ```rust,no_run
694 /// use sdk::{SigningClient, crypto::Signer};
695 ///
696 /// # async fn example(signer: Signer, url: &str) -> anyhow::Result<()> {
697 /// let client = SigningClient::new(signer, url, "real-t-0001")?;
698 /// client.verify_chain_id().await?; // refuses to continue on the wrong network
699 /// # Ok(())
700 /// # }
701 /// ```
702 ///
703 /// # Errors
704 ///
705 /// Fails if the endpoint reports a different chain id, or is unreachable, or
706 /// if this client has no configured chain id to check against — a client
707 /// that discovers its chain id cannot be verified, and reporting success
708 /// would be a false assurance (it would also mask a config value that
709 /// arrived empty).
710 pub async fn verify_chain_id(&self) -> anyhow::Result<()> {
711 let Some(configured) = &self.chain_id else {
712 anyhow::bail!(
713 "cannot verify chain id: none was configured, this client reads it \
714 from the endpoint — set one to assert which network you expect"
715 );
716 };
717
718 return self.http_client.verify_chain_id(configured).await;
719 }
720
721 /// Retrieves the version information of the matching engine.
722 ///
723 /// This method queries the matching engine to get its current version string,
724 /// which is useful for compatibility checking and debugging. It delegates
725 /// to the underlying HTTP client.
726 ///
727 /// # Returns
728 ///
729 /// * `Ok(String)` - The version string of the matching engine
730 /// * `Err(_)` - Network error or failed to parse response
731 ///
732 /// # Examples
733 ///
734 /// ```rust,no_run
735 /// use sdk::SigningClient;
736 ///
737 /// # async fn example(client: SigningClient) -> anyhow::Result<()> {
738 /// let version = client.get_version().await?;
739 /// println!("Engine version: {}", version);
740 /// # Ok(())
741 /// # }
742 /// ```
743 pub async fn get_version(&self) -> anyhow::Result<String> {
744 return self.http_client.get_version().await;
745 }
746
747 /// Performs a health check on the matching engine.
748 ///
749 /// This method pings the matching engine to verify it's running and responsive.
750 /// It's useful for monitoring, load balancer health checks, and ensuring
751 /// connectivity before attempting to send transactions. It delegates to
752 /// the underlying HTTP client.
753 ///
754 /// # Returns
755 ///
756 /// * `Ok(HealthCheck)` - The node answered. `status` is a constant `true` on
757 /// any node that answers at all, so the liveness signal is the other four
758 /// fields: `sequencer_height` / `sequencer_ts` (the last block the engine
759 /// opened and its unix-millisecond timestamp) and `settlement_chain_height`
760 /// / `settlement_chain_ts` (the last settlement-chain checkpoint ingested).
761 /// A node that has not produced a block yet reports zeros.
762 /// * `Err(_)` - Network error or failed to reach the matching engine
763 ///
764 /// # Examples
765 ///
766 /// ```rust,no_run
767 /// use sdk::{SigningClient, types::SubmitOrderRequest};
768 ///
769 /// # async fn example(client: SigningClient, order: SubmitOrderRequest) -> anyhow::Result<()> {
770 /// // Check health before trading. `status` is always true on a node that
771 /// // answers, so what tells you the engine is live is the heights moving.
772 /// let health = client.healthcheck().await?;
773 /// println!(
774 /// "sequencer {}@{} ms, settlement chain {}@{} ms",
775 /// health.sequencer_height,
776 /// health.sequencer_ts,
777 /// health.settlement_chain_height,
778 /// health.settlement_chain_ts,
779 /// );
780 ///
781 /// client.submit_order(order, None).await?;
782 /// # Ok(())
783 /// # }
784 /// ```
785 pub async fn healthcheck(&self) -> anyhow::Result<crate::client::HealthCheck> {
786 return self.http_client.healthcheck().await;
787 }
788
789 /// Generates the next unique nonce for transaction submission.
790 ///
791 /// This internal method generates monotonically increasing nonces based on
792 /// the current system time in microseconds. It handles concurrent access
793 /// by incrementing the nonce if the current time is not greater than the
794 /// last generated nonce.
795 ///
796 /// # Thread Safety
797 ///
798 /// This method is thread-safe and can be called concurrently. It uses a
799 /// mutex to ensure that each transaction gets a unique nonce even when
800 /// multiple threads are submitting transactions simultaneously.
801 ///
802 /// # Returns
803 ///
804 /// A unique nonce value (microseconds since Unix epoch, monotonically increasing)
805 fn next_nonce(&self) -> u64 {
806 let mut nonce = self.nonce.lock().unwrap();
807 let now = Self::now();
808
809 // in case we are accessing in parallel
810 // the nonce is already used
811 if *nonce >= now {
812 *nonce += 1;
813 } else {
814 *nonce = now;
815 }
816
817 return *nonce;
818 }
819
820 /// Returns the current time as microseconds since Unix epoch.
821 ///
822 /// This internal helper method is used for nonce generation. It converts
823 /// the system time to microseconds to provide high-resolution timestamps
824 /// for transaction uniqueness.
825 ///
826 /// # Returns
827 ///
828 /// Current time as microseconds since Unix epoch (u64)
829 ///
830 /// # Panics
831 ///
832 /// Panics if the system time is before the Unix epoch, which should never
833 /// happen on properly configured systems.
834 fn now() -> u64 {
835 return SystemTime::now()
836 .duration_since(UNIX_EPOCH)
837 .unwrap()
838 .as_micros() as u64;
839 }
840}