fireblocks_signer_transport/
lib.rs

1//! Fireblocks API client implementation.
2//!
3//! This module provides the core client functionality for interacting with the
4//! Fireblocks API. The [`Client`] struct handles authentication, request
5//! signing, and communication with Fireblocks services for transaction
6//! creation, signing, and status polling.
7//!
8//! The client supports both production and sandbox environments, with
9//! configurable timeouts, user agents, and connection parameters through the
10//! [`ClientBuilder`].
11
12mod error;
13mod jwt;
14mod models;
15
16pub use error::FireblocksClientError;
17pub use jwt::JwtSigner;
18pub use models::*;
19pub type Result<T> = std::result::Result<T, error::FireblocksClientError>;
20
21/// The production Fireblocks API endpoint.
22pub const FIREBLOCKS_API: &str = "https://api.fireblocks.io";
23
24/// The sandbox Fireblocks API endpoint for testing.
25pub const FIREBLOCKS_SANDBOX_API: &str = "https://sandbox-api.fireblocks.io";
26
27use {
28    jsonwebtoken::EncodingKey,
29    reqwest::blocking::RequestBuilder,
30    serde::de::DeserializeOwned,
31    std::{
32        fmt::{Debug, Display},
33        time::Duration,
34    },
35};
36
37/// A client for interacting with the Fireblocks API.
38///
39/// The [`Client`] handles all communication with Fireblocks services,
40/// including:
41/// - JWT-based authentication and request signing
42/// - Transaction creation and submission
43/// - Address retrieval from vaults
44/// - Transaction status polling
45/// - Error handling and response parsing
46///
47/// Clients are created using the [`ClientBuilder`] which allows configuration
48/// of timeouts, endpoints, and authentication credentials.
49#[derive(Clone, Default)]
50pub struct Client {
51    /// The base URL for the Fireblocks API endpoint.
52    url: String,
53    /// The underlying HTTP client for making requests.
54    client: reqwest::blocking::Client,
55    /// JWT signer for authenticating requests.
56    jwt: JwtSigner,
57}
58
59impl Debug for Client {
60    /// Formats the client for debugging without exposing sensitive information.
61    ///
62    /// This implementation avoids logging API keys, secrets, or other sensitive
63    /// authentication data that might be present in the client.
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.write_str("[fireblocks-client]")
66    }
67}
68
69// mod poll;
70// mod transfer;
71
72/// Builder for configuring and creating Fireblocks API clients.
73///
74/// The [`ClientBuilder`] provides a fluent interface for configuring various
75/// aspects of the Fireblocks client, including authentication credentials,
76/// network timeouts, API endpoints, and user agent strings.
77///
78/// Use [`ClientBuilder::new`] to create a builder with the required API key
79/// and secret, then chain configuration methods before calling [`build`] to
80/// create the final [`Client`].
81///
82/// [`build`]: ClientBuilder::build
83pub struct ClientBuilder {
84    /// The Fireblocks API key (UUID format).
85    api_key: String,
86    /// Request timeout duration.
87    timeout: Duration,
88    /// Connection timeout duration.
89    connect_timeout: Duration,
90    /// User agent string for HTTP requests.
91    user_agent: String,
92    /// RSA private key for JWT signing (PEM format).
93    secret: Vec<u8>,
94    /// Base URL for the Fireblocks API.
95    url: String,
96}
97
98impl Default for ClientBuilder {
99    /// Creates a default client builder configuration.
100    ///
101    /// Default values:
102    /// - `timeout`: 15 seconds
103    /// - `connect_timeout`: 5 seconds
104    /// - `user_agent`: "fireblocks-sdk-rs {version}"
105    /// - `url`: Production Fireblocks API endpoint
106    /// - `api_key` and `secret`: Empty (must be set via [`new`])
107    ///
108    /// [`new`]: ClientBuilder::new
109    fn default() -> Self {
110        Self {
111            api_key: String::new(),
112            timeout: Duration::from_secs(15),
113            connect_timeout: Duration::from_secs(5),
114            user_agent: format!("{} {}", env!["CARGO_PKG_NAME"], env!["CARGO_PKG_VERSION"]),
115            secret: vec![],
116            url: String::from(FIREBLOCKS_API),
117        }
118    }
119}
120
121impl ClientBuilder {
122    /// Creates a new client builder with the required authentication
123    /// credentials.
124    ///
125    /// # Arguments
126    ///
127    /// * `api_key` - The Fireblocks API key (UUID format)
128    /// * `secret` - The RSA private key in PEM format as bytes
129    ///
130    /// # Returns
131    ///
132    /// Returns a new [`ClientBuilder`] with the provided credentials and
133    /// default settings.
134    pub fn new(api_key: &str, secret: &[u8]) -> Self {
135        Self {
136            api_key: String::from(api_key),
137            secret: Vec::from(secret),
138            ..Default::default()
139        }
140    }
141
142    /// Configures the client to use the Fireblocks sandbox environment.
143    ///
144    /// This is an alias for [`with_sandbox`] provided for compatibility.
145    ///
146    /// [`with_sandbox`]: ClientBuilder::with_sandbox
147    #[allow(unused_mut, clippy::return_self_not_must_use)]
148    pub fn use_sandbox(mut self) -> Self {
149        self.with_url(FIREBLOCKS_SANDBOX_API)
150    }
151
152    /// Configures the client to use the Fireblocks sandbox environment.
153    ///
154    /// This sets the API endpoint to the sandbox URL for testing purposes.
155    /// Sandbox transactions do not affect real assets or balances.
156    #[allow(unused_mut, clippy::return_self_not_must_use)]
157    pub fn with_sandbox(mut self) -> Self {
158        self.with_url(FIREBLOCKS_SANDBOX_API)
159    }
160
161    /// Sets a custom API endpoint URL.
162    ///
163    /// # Arguments
164    ///
165    /// * `url` - The base URL for the Fireblocks API endpoint
166    ///
167    /// # Returns
168    ///
169    /// Returns the builder for method chaining.
170    #[allow(clippy::return_self_not_must_use)]
171    pub fn with_url(mut self, url: impl AsRef<str>) -> Self {
172        self.url = String::from(url.as_ref());
173        self
174    }
175
176    /// Sets the request timeout duration.
177    ///
178    /// This controls how long the client will wait for a response from
179    /// the Fireblocks API before timing out.
180    ///
181    /// # Arguments
182    ///
183    /// * `timeout` - The maximum duration to wait for API responses
184    ///
185    /// # Returns
186    ///
187    /// Returns the builder for method chaining.
188    #[allow(clippy::return_self_not_must_use)]
189    pub const fn with_timeout(mut self, timeout: Duration) -> Self {
190        self.timeout = timeout;
191        self
192    }
193
194    /// Sets the connection timeout duration.
195    ///
196    /// This controls how long the client will wait when establishing
197    /// a connection to the Fireblocks API.
198    ///
199    /// # Arguments
200    ///
201    /// * `timeout` - The maximum duration to wait for connection establishment
202    ///
203    /// # Returns
204    ///
205    /// Returns the builder for method chaining.
206    #[allow(clippy::return_self_not_must_use)]
207    pub const fn with_connect_timeout(mut self, timeout: Duration) -> Self {
208        self.connect_timeout = timeout;
209        self
210    }
211
212    /// Sets a custom user agent string for HTTP requests.
213    ///
214    /// # Arguments
215    ///
216    /// * `ua` - The user agent string to use in HTTP headers
217    ///
218    /// # Returns
219    ///
220    /// Returns the builder for method chaining.
221    #[allow(clippy::return_self_not_must_use)]
222    pub fn with_user_agent(mut self, ua: impl AsRef<str>) -> Self {
223        self.user_agent = String::from(ua.as_ref());
224        self
225    }
226
227    /// Builds the configured [`Client`].
228    ///
229    /// This method creates the JWT signer from the provided RSA key,
230    /// configures the HTTP client with the specified timeouts and user agent,
231    /// and returns a ready-to-use Fireblocks client.
232    ///
233    /// # Returns
234    ///
235    /// Returns a [`Result`] containing the configured [`Client`] on success.
236    ///
237    /// # Errors
238    ///
239    /// This method can fail if:
240    /// - The API key is not a valid UUID v4 format
241    /// - The RSA private key is invalid or cannot be parsed
242    /// - The HTTP client cannot be configured
243    /// - The JWT signer cannot be created
244    pub fn build(self) -> Result<Client> {
245        uuid::Uuid::parse_str(&self.api_key)
246            .map_err(|e| FireblocksClientError::InvalidApiKey(e.to_string()))?;
247        let key = EncodingKey::from_rsa_pem(&self.secret[..])?;
248        let signer = JwtSigner::new(key, &self.api_key);
249        let r = reqwest::blocking::ClientBuilder::new()
250            .timeout(self.timeout)
251            .connect_timeout(self.connect_timeout)
252            .user_agent(String::from(&self.user_agent))
253            .build()
254            .unwrap_or_default();
255        Ok(Client::new_with_url(&self.url, r, signer))
256    }
257}
258
259impl Client {
260    /// Creates a new client with the specified URL, HTTP client, and JWT
261    /// signer.
262    ///
263    /// This is an internal constructor used by the [`ClientBuilder`].
264    /// Use [`ClientBuilder`] to create clients instead of calling this
265    /// directly.
266    fn new_with_url(url: &str, client: reqwest::blocking::Client, jwt: JwtSigner) -> Self {
267        Self {
268            url: String::from(url),
269            client,
270            jwt,
271        }
272    }
273
274    /// Builds a complete API URL from a path.
275    ///
276    /// # Arguments
277    ///
278    /// * `path` - The API path to append to the base URL
279    ///
280    /// # Returns
281    ///
282    /// Returns the complete URL string.
283    fn build_url(&self, path: &str) -> String {
284        format!("{}{path}", self.url)
285    }
286
287    /// Sends an authenticated HTTP request and deserializes the response.
288    ///
289    /// This method handles the common pattern of adding authentication headers,
290    /// sending the request, checking the response status, and deserializing
291    /// the JSON response body.
292    ///
293    /// # Arguments
294    ///
295    /// * `req` - The HTTP request builder
296    /// * `jwt` - The JWT token for authentication
297    ///
298    /// # Returns
299    ///
300    /// Returns the deserialized response on success.
301    ///
302    /// # Errors
303    ///
304    /// This method can fail if:
305    /// - The HTTP request fails
306    /// - The server returns an error status
307    /// - The response body cannot be deserialized
308    fn send<T: DeserializeOwned>(&self, req: RequestBuilder, jwt: String) -> Result<T> {
309        let resp = req
310            .header("Authorization", jwt)
311            .header("X-API-KEY", self.jwt.api_key())
312            .send()?;
313        let status = resp.status();
314        let body = resp.text()?;
315        if !status.is_success() {
316            return Err(crate::FireblocksClientError::FireblocksServerError(body));
317        }
318
319        tracing::trace!("body response: {body}");
320        let result: serde_json::Result<T> = serde_json::from_str(&body);
321        match result {
322            Ok(r) => Ok(r),
323            Err(e) => Err(crate::FireblocksClientError::JsonParseErr(format!(
324                "Error {e}\nFailed to parse\n{body}"
325            ))),
326        }
327    }
328
329    /// Retrieves the public key address for a specific vault and asset.
330    ///
331    /// This method queries the Fireblocks API to get the first address
332    /// associated with the specified vault and asset combination.
333    ///
334    /// # Arguments
335    ///
336    /// * `vault` - The vault ID to query
337    /// * `asset` - The asset identifier (e.g., "SOL", "SOL_TEST")
338    ///
339    /// # Returns
340    ///
341    /// Returns the address associated with the vault and asset.
342    ///
343    /// # Errors
344    ///
345    /// This method can fail if:
346    /// - The API request fails
347    /// - The vault or asset doesn't exist
348    /// - No addresses are found for the vault/asset combination
349    /// - The response cannot be parsed
350    #[tracing::instrument(level = "debug")]
351    pub fn address(&self, vault: &str, asset: impl AsRef<str> + Display + Debug) -> Result<String> {
352        let path = format!("/v1/vault/accounts/{vault}/{asset}/addresses_paginated");
353        let url = self.build_url(&path);
354        let signed = self.jwt.sign(&path, &[])?;
355        let result: VaultAddressesResponse = self.send(self.client.get(url), signed)?;
356        if result.addresses.is_empty() {
357            return Err(crate::FireblocksClientError::FireblocksNoAddress(
358                vault.to_string(),
359            ));
360        }
361        Ok(result.addresses[0].address.clone())
362    }
363
364    /// Submits a Solana transaction to Fireblocks for signing and broadcasting.
365    ///
366    /// This method creates a Fireblocks transaction request with the provided
367    /// base64-encoded Solana transaction. Fireblocks will sign the transaction
368    /// and automatically broadcast it to the Solana network.
369    ///
370    /// # Arguments
371    ///
372    /// * `asset_id` - The asset identifier (e.g., "SOL", "SOL_TEST")
373    /// * `vault_id` - The vault ID containing the signing key
374    /// * `base64_tx` - The base64-encoded serialized Solana transaction
375    ///
376    /// # Returns
377    ///
378    /// Returns a [`CreateTransactionResponse`] containing the transaction ID
379    /// and initial status information.
380    ///
381    /// # Errors
382    ///
383    /// This method can fail if:
384    /// - The API request fails
385    /// - The transaction format is invalid
386    /// - The vault or asset doesn't exist
387    /// - Fireblocks rejects the transaction
388    #[tracing::instrument(level = "debug", skip(base64_tx))]
389    pub fn program_call(
390        &self,
391        asset_id: impl AsRef<str> + Debug,
392        vault_id: &str,
393        base64_tx: String,
394    ) -> Result<CreateTransactionResponse> {
395        let path = String::from("/v1/transactions");
396        let url = self.build_url(&path);
397        let extra = ExtraParameters::new(base64_tx);
398        let source = SourceTransferPeerPath::new(vault_id.to_string());
399        let tx = TransactionRequest::new(asset_id.as_ref().to_string(), source, extra);
400        let body = serde_json::to_vec(&tx)?;
401        let signed = self.jwt.sign(&path, &body)?;
402        let req = self
403            .client
404            .post(url)
405            .header("Content-Type", "application/json")
406            .body(body);
407
408        self.send(req, signed)
409    }
410
411    /// Submits a Solana transaction to Fireblocks for signing.
412    ///
413    /// This method creates a Fireblocks transaction request with the provided
414    /// base64-encoded Solana transaction. Fireblocks will sign the transaction
415    ///
416    /// # Arguments
417    ///
418    /// * `asset_id` - The asset identifier (e.g., "SOL", "SOL_TEST")
419    /// * `vault_id` - The vault ID containing the signing key
420    /// * `base64_tx` - The base64-encoded serialized Solana transaction
421    ///
422    /// # Returns
423    ///
424    /// Returns a [`CreateTransactionResponse`] containing the transaction ID
425    /// and initial status information.
426    ///
427    /// # Errors
428    ///
429    /// This method can fail if:
430    /// - The API request fails
431    /// - The transaction format is invalid
432    /// - The vault or asset doesn't exist
433    /// - Fireblocks rejects the transaction
434    #[tracing::instrument(level = "debug", skip(base64_tx))]
435    pub fn sign_only(
436        &self,
437        asset_id: impl AsRef<str> + Debug,
438        vault_id: &str,
439        base64_tx: String,
440    ) -> Result<CreateTransactionResponse> {
441        let path = String::from("/v1/transactions");
442        let url = self.build_url(&path);
443        let extra = ExtraParameters {
444            program_call_data: base64_tx,
445            use_durable_nonce: Some(false),
446            sign_only: Some(true),
447        };
448
449        let source = SourceTransferPeerPath::new(vault_id.to_string());
450        let tx = TransactionRequest::new(asset_id.as_ref().to_string(), source, extra);
451        let body = serde_json::to_vec(&tx)?;
452        let signed = self.jwt.sign(&path, &body)?;
453        let req = self
454            .client
455            .post(url)
456            .header("Content-Type", "application/json")
457            .body(body);
458
459        self.send(req, signed)
460    }
461
462    /// Retrieves the current status and details of a transaction.
463    ///
464    /// This method queries Fireblocks for the current state of a transaction,
465    /// including its status, signatures, and other metadata.
466    ///
467    /// # Arguments
468    ///
469    /// * `txid` - The Fireblocks transaction ID
470    ///
471    /// # Returns
472    ///
473    /// Returns a tuple containing:
474    /// - [`TransactionResponse`] with full transaction details
475    /// - [`Option<String>`] the blockchain tx hash
476    ///
477    /// # Errors
478    ///
479    /// This method can fail if:
480    /// - The API request fails
481    /// - The transaction ID doesn't exist
482    /// - The response cannot be parsed
483    pub fn get_tx(&self, txid: &str) -> Result<(TransactionResponse, Option<String>)> {
484        let path = format!("/v1/transactions/{txid}");
485        let url = self.build_url(&path);
486        let signed = self.jwt.sign(&path, &[])?;
487        let result: TransactionResponse = self.send(self.client.get(&url), signed)?;
488        let tx_hash = result.tx_hash.clone();
489        Ok((result, tx_hash))
490    }
491
492    /// Polls a transaction until it reaches a final state or times out.
493    ///
494    /// This method repeatedly checks the transaction status at the specified
495    /// interval until the transaction completes, fails, or the timeout is
496    /// reached. The callback function is called on each polling iteration
497    /// with the current transaction status.
498    ///
499    /// # Arguments
500    ///
501    /// * `txid` - The Fireblocks transaction ID to poll
502    /// * `timeout` - Maximum time to wait for transaction completion
503    /// * `interval` - Time to wait between polling requests
504    /// * `callback` - Function called with each transaction status update
505    ///
506    /// # Returns
507    ///
508    /// Returns a tuple containing:
509    /// - [`TransactionResponse`] with the final transaction state
510    /// - [`Option<String>`] the blockchain tx hash
511    ///
512    /// # Errors
513    ///
514    /// This method can fail if:
515    /// - Any individual status check fails
516    /// - The transaction cannot be retrieved
517    ///
518    /// # Behavior
519    ///
520    /// The method considers these statuses as final:
521    /// - `Blocked`, `Cancelled`, `Cancelling` - Transaction was stopped
522    /// - `Completed`, `Confirming` - Transaction succeeded
523    /// - `Failed`, `Rejected` - Transaction failed
524    ///
525    /// All other statuses are considered in-progress and will continue polling.
526    pub fn poll(
527        &self,
528        txid: &str,
529        timeout: std::time::Duration,
530        interval: std::time::Duration,
531        callback: impl Fn(&TransactionResponse),
532    ) -> Result<(TransactionResponse, Option<String>)> {
533        let deadline = std::time::Instant::now() + timeout;
534
535        loop {
536            let (result, sig) = self.get_tx(txid)?;
537            if result.status.is_done() {
538                return Ok((result, sig));
539            }
540            callback(&result);
541            // Check if we have time for another iteration
542            let now = std::time::Instant::now();
543            // Sleep for the interval or remaining time, whichever is shorter
544            let remaining = deadline - now;
545            let sleep_duration = interval.min(remaining);
546            std::thread::sleep(sleep_duration);
547
548            if now >= deadline {
549                tracing::warn!(
550                    "timeout while waiting for transaction confirmation {}",
551                    result.id
552                );
553                break;
554            }
555        }
556        // Maybe last call will be lucky
557        self.get_tx(txid)
558    }
559}