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}