Skip to main content

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}