tx3_sdk/trp/mod.rs
1//! Transaction Resolve Protocol (TRP) Client
2//!
3//! This module provides a client for interacting with the Transaction Resolve Protocol (TRP),
4//! a JSON-RPC based protocol for resolving, submitting, and tracking UTxO transactions.
5//!
6//! ## Key Features
7//!
8//! - **Transaction Resolution**: Convert TX3 transaction templates into concrete UTxO transactions
9//! - **Transaction Submission**: Submit signed transactions to the network
10//! - **Status Monitoring**: Track transaction lifecycle from pending to finalization
11//! - **Queue Inspection**: Peek at pending and in-flight transactions
12//! - **Log Access**: Query historical transaction logs
13//!
14//! ## Usage Example
15//!
16//! ```ignore
17//! use tx3_sdk::trp::{Client, ClientOptions, ResolveParams, SubmitParams};
18//! use tx3_sdk::core::TirEnvelope;
19//!
20//! // Create TRP client
21//! let client = Client::new(ClientOptions {
22//! endpoint: "https://trp.example.com".to_string(),
23//! headers: None,
24//! });
25//!
26//! // Resolve a transaction
27//! let params = ResolveParams {
28//! tir: TirEnvelope { /* ... */ },
29//! args: serde_json::Map::new(),
30//! env: None,
31//! };
32//!
33//! let tx_envelope = client.resolve(params).await?;
34//! println!("Resolved transaction hash: {}", tx_envelope.hash);
35//!
36//! // Check status
37//! let status = client.check_status(vec![tx_envelope.hash]).await?;
38//! ```
39
40use reqwest::header;
41use serde::{de::DeserializeOwned, Deserialize, Serialize};
42use serde_json::Value;
43use std::collections::HashMap;
44use thiserror::Error;
45use uuid::Uuid;
46
47pub use crate::trp::spec::{
48 ChainPoint, CheckStatusResponse, DumpLogsResponse, InflightTx, InputNotResolvedDiagnostic,
49 MissingTxArgDiagnostic, PeekInflightResponse, PeekPendingResponse, PendingTx, ResolveParams,
50 SubmitParams, SubmitResponse, TxEnvelope, TxLog, TxScriptFailureDiagnostic, TxStage, TxStatus,
51 TxStatusMap, TxWitness, UnsupportedTirDiagnostic, WitnessType,
52};
53
54mod spec;
55
56/// Error type for TRP client operations.
57///
58/// This enum represents all possible errors that can occur when interacting
59/// with the TRP protocol, including network errors, HTTP errors, deserialization
60/// errors, and specific TRP protocol errors.
61#[derive(Debug, Error)]
62pub enum Error {
63 /// Network error from the underlying HTTP client.
64 #[error("network error: {0}")]
65 NetworkError(#[from] reqwest::Error),
66
67 /// HTTP error with status code and message.
68 #[error("HTTP error {0}: {1}")]
69 HttpError(u16, String),
70
71 /// Failed to deserialize the response from the server.
72 #[error("Failed to deserialize response: {0}")]
73 DeserializationError(String),
74
75 /// Generic JSON-RPC error with code, message, and optional data.
76 #[error("({0}) {1}")]
77 GenericRpcError(i32, String, Option<Value>),
78
79 /// Unknown error with a message.
80 #[error("Unknown error: {0}")]
81 UnknownError(String),
82
83 /// The TIR version provided is not supported by the server.
84 ///
85 /// Contains the expected and provided version information.
86 #[error("TIR version {provided} is not supported, expected {expected}", provided = .0.provided, expected = .0.expected)]
87 UnsupportedTir(UnsupportedTirDiagnostic),
88
89 /// The TIR envelope format is invalid.
90 #[error("invalid TIR envelope")]
91 InvalidTirEnvelope,
92
93 /// Failed to decode the intermediate representation bytes.
94 #[error("failed to decode IR bytes")]
95 InvalidTirBytes,
96
97 /// Only transactions from the Conway era are supported.
98 #[error("only txs from Conway era are supported")]
99 UnsupportedTxEra,
100
101 /// The node cannot resolve transactions while running at the specified era.
102 #[error("node can't resolve txs while running at era {era}")]
103 UnsupportedEra {
104 /// The era that doesn't support transaction resolution.
105 era: String,
106 },
107
108 /// A required transaction argument is missing.
109 ///
110 /// Contains the name and expected type of the missing argument.
111 #[error("missing argument `{key}` of type {ty}", key = .0.key, ty = .0.arg_type)]
112 MissingTxArg(MissingTxArgDiagnostic),
113
114 /// An input could not be resolved during transaction construction.
115 ///
116 /// Contains diagnostic information about the failed query.
117 #[error("input `{name}` not resolved", name = .0.name)]
118 InputNotResolved(Box<InputNotResolvedDiagnostic>),
119
120 /// The transaction script execution failed.
121 ///
122 /// Contains log output from the failed script.
123 #[error("tx script returned failure")]
124 TxScriptFailure(TxScriptFailureDiagnostic),
125}
126
127impl Error {
128 fn generic(payload: JsonRpcError) -> Self {
129 Self::GenericRpcError(payload.code, payload.message, payload.data)
130 }
131}
132
133fn expect_json_rpc_error_data<T: DeserializeOwned>(payload: JsonRpcError) -> Result<T, Error> {
134 let Some(data) = payload.data.clone() else {
135 return Err(Error::generic(payload));
136 };
137
138 let Ok(data) = serde_json::from_value(data.clone()) else {
139 return Err(Error::generic(payload));
140 };
141
142 Ok(data)
143}
144
145impl From<JsonRpcError> for Error {
146 fn from(error: JsonRpcError) -> Self {
147 match error.code {
148 -32000 => match expect_json_rpc_error_data(error) {
149 Ok(data) => Error::UnsupportedTir(data),
150 Err(e) => e,
151 },
152 -32001 => match expect_json_rpc_error_data(error) {
153 Ok(data) => Error::MissingTxArg(data),
154 Err(e) => e,
155 },
156 -32002 => match expect_json_rpc_error_data(error) {
157 Ok(data) => Error::InputNotResolved(Box::new(data)),
158 Err(e) => e,
159 },
160 -32003 => match expect_json_rpc_error_data(error) {
161 Ok(data) => Error::TxScriptFailure(data),
162 Err(e) => e,
163 },
164 _ => Error::generic(error),
165 }
166 }
167}
168
169/// Configuration options for the TRP client.
170///
171/// This structure holds the configuration needed to create a TRP client,
172/// including the endpoint URL and optional custom headers.
173///
174/// # Example
175///
176/// ```ignore
177/// use tx3_sdk::trp::ClientOptions;
178/// use std::collections::HashMap;
179///
180/// let mut headers = HashMap::new();
181/// headers.insert("Authorization".to_string(), "Bearer token123".to_string());
182///
183/// let options = ClientOptions {
184/// endpoint: "https://trp.example.com".to_string(),
185/// headers: Some(headers),
186/// };
187/// ```
188#[derive(Debug, Clone)]
189pub struct ClientOptions {
190 /// The TRP server endpoint URL.
191 pub endpoint: String,
192
193 /// Optional custom HTTP headers to include in requests.
194 pub headers: Option<HashMap<String, String>>,
195}
196
197/// JSON-RPC request structure.
198///
199/// Internal structure used to serialize JSON-RPC requests to the TRP server.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct JsonRpcRequest {
202 /// JSON-RPC version (always "2.0").
203 pub jsonrpc: String,
204
205 /// The method name to call.
206 pub method: String,
207
208 /// The method parameters.
209 pub params: serde_json::Value,
210
211 /// Request ID (UUID).
212 pub id: String,
213}
214
215#[derive(Debug, Deserialize)]
216struct JsonRpcResponse {
217 result: Option<serde_json::Value>,
218 error: Option<JsonRpcError>,
219}
220
221#[derive(Debug, Deserialize)]
222struct JsonRpcError {
223 code: i32,
224 message: String,
225 data: Option<Value>,
226}
227
228/// Client for the Transaction Resolve Protocol (TRP).
229///
230/// This client provides methods for interacting with a TRP server to resolve
231/// transaction templates, submit signed transactions, and monitor transaction
232/// status.
233///
234/// The client is cloneable and can be reused across multiple requests.
235///
236/// # Example
237///
238/// ```ignore
239/// use tx3_sdk::trp::{Client, ClientOptions};
240///
241/// let client = Client::new(ClientOptions {
242/// endpoint: "https://trp.example.com".to_string(),
243/// headers: None,
244/// });
245///
246/// // Use the client for multiple operations
247/// let tx = client.resolve(params).await?;
248/// let status = client.check_status(vec![tx.hash]).await?;
249/// ```
250#[derive(Clone)]
251pub struct Client {
252 options: ClientOptions,
253 client: reqwest::Client,
254}
255
256impl Client {
257 /// Creates a new TRP client with the given options.
258 ///
259 /// # Arguments
260 ///
261 /// * `options` - Configuration options including endpoint URL and optional headers
262 ///
263 /// # Example
264 ///
265 /// ```ignore
266 /// use tx3_sdk::trp::{Client, ClientOptions};
267 ///
268 /// let client = Client::new(ClientOptions {
269 /// endpoint: "https://trp.example.com".to_string(),
270 /// headers: None,
271 /// });
272 /// ```
273 pub fn new(options: ClientOptions) -> Self {
274 Self {
275 options,
276 client: reqwest::Client::new(),
277 }
278 }
279
280 /// Makes a raw JSON-RPC call to the TRP server.
281 ///
282 /// This is a low-level method for making JSON-RPC calls. Generally, you should
283 /// use the higher-level methods like `resolve`, `submit`, etc.
284 ///
285 /// # Arguments
286 ///
287 /// * `method` - The JSON-RPC method name
288 /// * `params` - The method parameters as a JSON value
289 ///
290 /// # Returns
291 ///
292 /// Returns the result as a JSON value on success, or an error on failure.
293 pub async fn call(
294 &self,
295 method: &str,
296 params: serde_json::Value,
297 ) -> Result<serde_json::Value, Error> {
298 // Prepare headers
299 let mut headers = header::HeaderMap::new();
300 headers.insert(
301 header::CONTENT_TYPE,
302 header::HeaderValue::from_static("application/json"),
303 );
304
305 if let Some(user_headers) = &self.options.headers {
306 for (key, value) in user_headers {
307 if let Ok(header_name) = header::HeaderName::from_bytes(key.as_bytes()) {
308 if let Ok(header_value) = header::HeaderValue::from_str(value) {
309 headers.insert(header_name, header_value);
310 }
311 }
312 }
313 }
314
315 // Prepare request body with FlattenedArgs for proper serialization
316 let body = JsonRpcRequest {
317 jsonrpc: "2.0".to_string(),
318 method: method.to_string(),
319 params,
320 id: Uuid::new_v4().to_string(),
321 };
322
323 // Send request
324 let response = self
325 .client
326 .post(&self.options.endpoint)
327 .headers(headers)
328 .json(&serde_json::to_value(body).unwrap())
329 .send()
330 .await
331 .map_err(Error::from)?;
332
333 // If the response at the HTTP level is not successful, return an error
334 if !response.status().is_success() {
335 return Err(Error::HttpError(
336 response.status().as_u16(),
337 response.status().to_string(),
338 ));
339 }
340
341 // Parse response
342 let result: JsonRpcResponse = response
343 .json()
344 .await
345 .map_err(|e| Error::DeserializationError(e.to_string()))?;
346
347 // Handle possible error
348 if let Some(error) = result.error {
349 return Err(Error::from(error));
350 }
351
352 result
353 .result
354 .ok_or_else(|| Error::UnknownError("No result in response".to_string()))
355 }
356
357 /// Resolves a transaction template into a concrete transaction.
358 ///
359 /// This method takes a Transaction Intermediate Representation (TIR) envelope
360 /// and arguments, and resolves it into a concrete UTxO transaction ready
361 /// for signing.
362 ///
363 /// # Arguments
364 ///
365 /// * `request` - The resolve parameters including TIR and arguments
366 ///
367 /// # Returns
368 ///
369 /// Returns a `TxEnvelope` containing the resolved transaction hash and CBOR bytes.
370 ///
371 /// # Errors
372 ///
373 /// Can return various errors including:
374 /// - `Error::UnsupportedTir` if the TIR version is not supported
375 /// - `Error::MissingTxArg` if required arguments are missing
376 /// - `Error::InputNotResolved` if an input cannot be found
377 /// - `Error::TxScriptFailure` if script execution fails
378 ///
379 /// # Example
380 ///
381 /// ```ignore
382 /// use tx3_sdk::trp::{Client, ResolveParams};
383 /// use tx3_sdk::core::TirEnvelope;
384 ///
385 /// let client = Client::new(/* ... */);
386 ///
387 /// let params = ResolveParams {
388 /// tir: TirEnvelope { /* ... */ },
389 /// args: serde_json::Map::new(),
390 /// env: None,
391 /// };
392 ///
393 /// let tx = client.resolve(params).await?;
394 /// println!("Resolved hash: {}", tx.hash);
395 /// ```
396 pub async fn resolve(&self, request: ResolveParams) -> Result<TxEnvelope, Error> {
397 let params = serde_json::to_value(request).unwrap();
398
399 let response = self.call("trp.resolve", params).await?;
400
401 // Return result
402 let out = serde_json::from_value(response)
403 .map_err(|e| Error::DeserializationError(e.to_string()))?;
404
405 Ok(out)
406 }
407
408 /// Submits a signed transaction to the network.
409 ///
410 /// This method submits a signed transaction with its witnesses to the
411 /// blockchain network via the TRP server.
412 ///
413 /// # Arguments
414 ///
415 /// * `request` - The submit parameters including transaction bytes and witnesses
416 ///
417 /// # Returns
418 ///
419 /// Returns a `SubmitResponse` containing the submitted transaction hash.
420 ///
421 /// # Example
422 ///
423 /// ```ignore
424 /// use tx3_sdk::trp::{Client, SubmitParams, TxWitness, WitnessType};
425 /// use tx3_sdk::core::BytesEnvelope;
426 ///
427 /// let client = Client::new(/* ... */);
428 ///
429 /// let params = SubmitParams {
430 /// tx: BytesEnvelope { /* signed tx */ },
431 /// witnesses: vec![TxWitness { /* ... */ }],
432 /// };
433 ///
434 /// let response = client.submit(params).await?;
435 /// println!("Submitted: {}", response.hash);
436 /// ```
437 pub async fn submit(&self, request: SubmitParams) -> Result<SubmitResponse, Error> {
438 let params = serde_json::to_value(request).unwrap();
439
440 let response = self.call("trp.submit", params).await?;
441
442 let out = serde_json::from_value(response)
443 .map_err(|e| Error::DeserializationError(e.to_string()))?;
444
445 Ok(out)
446 }
447
448 /// Checks the status of one or more transactions.
449 ///
450 /// This method queries the TRP server for the current status of the
451 /// specified transactions.
452 ///
453 /// # Arguments
454 ///
455 /// * `hashes` - Vector of transaction hashes to check
456 ///
457 /// # Returns
458 ///
459 /// Returns a `CheckStatusResponse` containing a map of transaction hashes
460 /// to their current status.
461 ///
462 /// # Example
463 ///
464 /// ```ignore
465 /// use tx3_sdk::trp::Client;
466 ///
467 /// let client = Client::new(/* ... */);
468 ///
469 /// let hashes = vec!["abc123...".to_string()];
470 /// let status = client.check_status(hashes).await?;
471 ///
472 /// for (hash, tx_status) in status.statuses {
473 /// println!("{}: {:?}", hash, tx_status.stage);
474 /// }
475 /// ```
476 pub async fn check_status(&self, hashes: Vec<String>) -> Result<CheckStatusResponse, Error> {
477 let params = serde_json::json!({ "hashes": hashes });
478
479 let response = self.call("trp.checkStatus", params).await?;
480
481 let out = serde_json::from_value(response)
482 .map_err(|e| Error::DeserializationError(e.to_string()))?;
483
484 Ok(out)
485 }
486
487 /// Dumps transaction logs with optional pagination.
488 ///
489 /// This method retrieves a paginated list of transaction log entries,
490 /// useful for monitoring and auditing transaction history.
491 ///
492 /// # Arguments
493 ///
494 /// * `cursor` - Optional pagination cursor for fetching specific pages
495 /// * `limit` - Optional limit on the number of entries to return
496 /// * `include_payload` - Whether to include transaction payloads in the response
497 ///
498 /// # Returns
499 ///
500 /// Returns a `DumpLogsResponse` containing log entries and an optional
501 /// next cursor for pagination.
502 ///
503 /// # Example
504 ///
505 /// ```ignore
506 /// use tx3_sdk::trp::Client;
507 ///
508 /// let client = Client::new(/* ... */);
509 ///
510 /// // Get first page with 100 entries
511 /// let logs = client.dump_logs(None, Some(100), Some(false)).await?;
512 ///
513 /// for entry in logs.entries {
514 /// println!("{}: {:?}", entry.hash, entry.stage);
515 /// }
516 ///
517 /// // Get next page if available
518 /// if let Some(next) = logs.next_cursor {
519 /// let more_logs = client.dump_logs(Some(next), Some(100), Some(false)).await?;
520 /// }
521 /// ```
522 pub async fn dump_logs(
523 &self,
524 cursor: Option<u64>,
525 limit: Option<u64>,
526 include_payload: Option<bool>,
527 ) -> Result<DumpLogsResponse, Error> {
528 let mut params = serde_json::Map::new();
529 if let Some(cursor) = cursor {
530 params.insert("cursor".to_string(), serde_json::json!(cursor));
531 }
532 if let Some(limit) = limit {
533 params.insert("limit".to_string(), serde_json::json!(limit));
534 }
535 if let Some(include_payload) = include_payload {
536 params.insert(
537 "includePayload".to_string(),
538 serde_json::json!(include_payload),
539 );
540 }
541
542 let response = self
543 .call("trp.dumpLogs", serde_json::Value::Object(params))
544 .await?;
545
546 let out = serde_json::from_value(response)
547 .map_err(|e| Error::DeserializationError(e.to_string()))?;
548
549 Ok(out)
550 }
551
552 /// Peeks at pending transactions in the mempool.
553 ///
554 /// This method retrieves pending transactions that are waiting to be
555 /// included in a block, useful for monitoring mempool state.
556 ///
557 /// # Arguments
558 ///
559 /// * `limit` - Optional limit on the number of pending transactions to return
560 /// * `include_payload` - Whether to include transaction payloads in the response
561 ///
562 /// # Returns
563 ///
564 /// Returns a `PeekPendingResponse` containing pending transactions.
565 ///
566 /// # Example
567 ///
568 /// ```ignore
569 /// use tx3_sdk::trp::Client;
570 ///
571 /// let client = Client::new(/* ... */);
572 ///
573 /// let pending = client.peek_pending(Some(50), Some(false)).await?;
574 ///
575 /// println!("Found {} pending transactions", pending.entries.len());
576 /// if pending.has_more {
577 /// println!("More transactions available");
578 /// }
579 /// ```
580 pub async fn peek_pending(
581 &self,
582 limit: Option<u64>,
583 include_payload: Option<bool>,
584 ) -> Result<PeekPendingResponse, Error> {
585 let mut params = serde_json::Map::new();
586 if let Some(limit) = limit {
587 params.insert("limit".to_string(), serde_json::json!(limit));
588 }
589 if let Some(include_payload) = include_payload {
590 params.insert(
591 "includePayload".to_string(),
592 serde_json::json!(include_payload),
593 );
594 }
595
596 let response = self
597 .call("trp.peekPending", serde_json::Value::Object(params))
598 .await?;
599
600 let out = serde_json::from_value(response)
601 .map_err(|e| Error::DeserializationError(e.to_string()))?;
602
603 Ok(out)
604 }
605
606 /// Peeks at in-flight transactions being tracked by the server.
607 ///
608 /// This method retrieves transactions that have been submitted and are
609 /// being tracked through their lifecycle stages.
610 ///
611 /// # Arguments
612 ///
613 /// * `limit` - Optional limit on the number of in-flight transactions to return
614 /// * `include_payload` - Whether to include transaction payloads in the response
615 ///
616 /// # Returns
617 ///
618 /// Returns a `PeekInflightResponse` containing in-flight transactions.
619 ///
620 /// # Example
621 ///
622 /// ```ignore
623 /// use tx3_sdk::trp::Client;
624 ///
625 /// let client = Client::new(/* ... */);
626 ///
627 /// let inflight = client.peek_inflight(Some(50), Some(false)).await?;
628 ///
629 /// for tx in inflight.entries {
630 /// println!("{}: {:?} ({} confirmations)",
631 /// tx.hash, tx.stage, tx.confirmations);
632 /// }
633 /// ```
634 pub async fn peek_inflight(
635 &self,
636 limit: Option<u64>,
637 include_payload: Option<bool>,
638 ) -> Result<PeekInflightResponse, Error> {
639 let mut params = serde_json::Map::new();
640 if let Some(limit) = limit {
641 params.insert("limit".to_string(), serde_json::json!(limit));
642 }
643 if let Some(include_payload) = include_payload {
644 params.insert(
645 "includePayload".to_string(),
646 serde_json::json!(include_payload),
647 );
648 }
649
650 let response = self
651 .call("trp.peekInflight", serde_json::Value::Object(params))
652 .await?;
653
654 let out = serde_json::from_value(response)
655 .map_err(|e| Error::DeserializationError(e.to_string()))?;
656
657 Ok(out)
658 }
659}