amp_rs/client.rs
1use std::env;
2use std::sync::Arc;
3use std::time::Duration as StdDuration;
4
5use async_trait::async_trait;
6use chrono::{Duration, Utc};
7
8use reqwest::header::AUTHORIZATION;
9use reqwest::{Client, Method, Url};
10use serde::de::DeserializeOwned;
11use thiserror::Error;
12use tokio::sync::{Mutex, OnceCell, Semaphore};
13use tokio::time::sleep;
14
15use elements::encode::Decodable;
16use secrecy::ExposeSecret;
17use secrecy::Secret;
18use std::str::FromStr;
19
20use crate::model::{
21 Activity, Asset, AssetActivityParams, AssetDistributionAssignment, AssetSummary, Assignment,
22 Balance, BroadcastResponse, CategoriesRequest, CategoryAdd, CategoryEdit, CategoryResponse,
23 ChangePasswordRequest, ChangePasswordResponse, CreateAssetAssignmentRequest, EditAssetRequest,
24 GaidBalanceEntry, IssuanceRequest, IssuanceResponse, Outpoint, Ownership, Password,
25 ReceivedByAddress, RegisterAssetResponse, TokenData, TokenInfo, TokenRequest, TokenResponse,
26 TransactionDetail, TxInput, Unspent, Utxo,
27};
28use crate::signer::{Signer, SignerError};
29
30/// Environment variables used for token environment detection
31#[derive(Debug)]
32struct EnvironmentVariables {
33 username: String,
34 password: String,
35 amp_tests: String,
36 base_url: String,
37}
38
39/// Token environment detection for automatic strategy selection
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum TokenEnvironment {
42 /// Mock environment - use isolated token management without persistence
43 Mock,
44 /// Live environment - use full token management with persistence
45 Live,
46 /// Auto-detect environment based on credentials and settings
47 Auto,
48}
49
50impl TokenEnvironment {
51 /// Detects the current token environment based on environment variables and credential patterns
52 ///
53 /// Detection logic:
54 /// 1. If `AMP_TESTS=live` is set, returns `Live`
55 /// 2. If credentials contain "mock" string, returns `Mock`
56 /// 3. If real credentials are present without live test flag, returns `Live`
57 /// 4. Fallback to `Mock` for safety
58 #[must_use]
59 pub fn detect() -> Self {
60 let env_vars = Self::read_environment_variables();
61 Self::log_detection_start(&env_vars);
62
63 if Self::is_explicit_live_environment(&env_vars.amp_tests) {
64 return Self::Live;
65 }
66
67 if Self::has_mock_credentials(&env_vars.username, &env_vars.password, &env_vars.base_url) {
68 Self::log_detection_result("mock environment via mock credentials");
69 return Self::Mock;
70 }
71
72 if Self::has_real_credentials(&env_vars.username, &env_vars.password) {
73 Self::log_detection_result("live environment via real credentials");
74 return Self::Live;
75 }
76
77 Self::log_detection_result("mock environment via fallback (no credentials)");
78 Self::Mock
79 }
80
81 /// Reads environment variables needed for token environment detection
82 fn read_environment_variables() -> EnvironmentVariables {
83 EnvironmentVariables {
84 username: env::var("AMP_USERNAME").unwrap_or_default(),
85 password: env::var("AMP_PASSWORD").unwrap_or_default(),
86 amp_tests: env::var("AMP_TESTS").unwrap_or_default(),
87 base_url: env::var("AMP_API_BASE_URL").unwrap_or_default(),
88 }
89 }
90
91 /// Logs the start of environment detection with current variable values
92 fn log_detection_start(env_vars: &EnvironmentVariables) {
93 tracing::debug!(
94 "Detecting token environment - AMP_TESTS: '{}', username: '{}', base_url: '{}'",
95 env_vars.amp_tests,
96 env_vars.username,
97 env_vars.base_url
98 );
99 }
100
101 /// Checks if the environment is explicitly set to live testing
102 fn is_explicit_live_environment(amp_tests: &str) -> bool {
103 if amp_tests == "live" {
104 Self::log_detection_result("live environment via AMP_TESTS=live");
105 true
106 } else {
107 false
108 }
109 }
110
111 /// Checks if real (non-empty) credentials are present
112 const fn has_real_credentials(username: &str, password: &str) -> bool {
113 !username.is_empty() && !password.is_empty()
114 }
115
116 /// Logs the final detection result
117 fn log_detection_result(reason: &str) {
118 tracing::info!("Detected {}", reason);
119 }
120
121 /// Checks if the provided credentials indicate a mock environment
122 ///
123 /// Mock credentials are detected by:
124 /// - Username containing "mock" (case-insensitive)
125 /// - Password containing "mock" (case-insensitive)
126 /// - Base URL containing localhost, 127.0.0.1, or "mock"
127 #[must_use]
128 pub fn has_mock_credentials(username: &str, password: &str, base_url: &str) -> bool {
129 let username_lower = username.to_lowercase();
130 let password_lower = password.to_lowercase();
131 let base_url_lower = base_url.to_lowercase();
132
133 let has_mock_username = username_lower.contains("mock");
134 let has_mock_password = password_lower.contains("mock");
135 let has_mock_url = base_url_lower.contains("localhost")
136 || base_url_lower.contains("127.0.0.1")
137 || base_url_lower.contains("mock");
138
139 let is_mock = has_mock_username || has_mock_password || has_mock_url;
140
141 tracing::debug!(
142 "Mock credential check - username: {}, password: {}, url: {}, result: {}",
143 has_mock_username,
144 has_mock_password,
145 has_mock_url,
146 is_mock
147 );
148
149 is_mock
150 }
151
152 /// Creates a token strategy based on the environment type
153 ///
154 /// # Arguments
155 /// * `mock_token` - Optional mock token to use for mock environments
156 ///
157 /// # Errors
158 /// Returns an error if strategy creation fails
159 pub async fn create_strategy(
160 &self,
161 mock_token: Option<String>,
162 ) -> Result<Box<dyn TokenStrategy>, Error> {
163 match self {
164 Self::Mock => Ok(Self::create_mock_strategy(mock_token)),
165 Self::Live => Self::create_live_strategy().await,
166 Self::Auto => Self::create_auto_detected_strategy(mock_token).await,
167 }
168 }
169
170 /// Creates a mock token strategy with the provided or default token
171 fn create_mock_strategy(mock_token: Option<String>) -> Box<dyn TokenStrategy> {
172 let token = mock_token.unwrap_or_else(|| "default_mock_token".to_string());
173 tracing::debug!("Creating mock token strategy with token");
174 Box::new(MockTokenStrategy::new(token))
175 }
176
177 /// Creates a live token strategy
178 async fn create_live_strategy() -> Result<Box<dyn TokenStrategy>, Error> {
179 tracing::debug!("Creating live token strategy");
180 let strategy = LiveTokenStrategy::new().await?;
181 Ok(Box::new(strategy))
182 }
183
184 /// Creates a strategy based on auto-detected environment
185 async fn create_auto_detected_strategy(
186 mock_token: Option<String>,
187 ) -> Result<Box<dyn TokenStrategy>, Error> {
188 tracing::debug!("Auto-detecting environment for strategy creation");
189 let detected = Self::detect();
190
191 match detected {
192 Self::Mock => Ok(Self::create_auto_detected_mock_strategy(mock_token)),
193 Self::Live => Self::create_auto_detected_live_strategy().await,
194 Self::Auto => Self::handle_unexpected_auto_detection(),
195 }
196 }
197
198 /// Creates a mock strategy for auto-detected mock environment
199 fn create_auto_detected_mock_strategy(mock_token: Option<String>) -> Box<dyn TokenStrategy> {
200 let token = mock_token.unwrap_or_else(|| "default_mock_token".to_string());
201 tracing::debug!("Auto-detected mock environment, creating mock strategy");
202 Box::new(MockTokenStrategy::new(token))
203 }
204
205 /// Creates a live strategy for auto-detected live environment
206 async fn create_auto_detected_live_strategy() -> Result<Box<dyn TokenStrategy>, Error> {
207 tracing::debug!("Auto-detected live environment, creating live strategy");
208 let strategy = LiveTokenStrategy::new().await?;
209 Ok(Box::new(strategy))
210 }
211
212 /// Handles the unexpected case where `detect()` returns Auto
213 fn handle_unexpected_auto_detection() -> Result<Box<dyn TokenStrategy>, Error> {
214 tracing::error!("Unexpected Auto environment from detect()");
215 Err(Error::Token(TokenError::validation(
216 "Environment detection returned Auto, which should not happen".to_string(),
217 )))
218 }
219
220 /// Creates a token strategy with automatic environment detection
221 ///
222 /// This is a convenience method that combines environment detection with strategy creation.
223 ///
224 /// # Arguments
225 /// * `mock_token` - Optional mock token to use if mock environment is detected
226 ///
227 /// # Errors
228 /// Returns an error if strategy creation fails
229 pub async fn create_auto_strategy(
230 mock_token: Option<String>,
231 ) -> Result<Box<dyn TokenStrategy>, Error> {
232 let environment = Self::detect();
233 environment.create_strategy(mock_token).await
234 }
235
236 /// Determines if token persistence should be enabled for this environment
237 #[must_use]
238 pub fn should_persist_tokens(&self) -> bool {
239 match self {
240 Self::Mock => false,
241 Self::Live => true,
242 Self::Auto => Self::detect().should_persist_tokens(),
243 }
244 }
245
246 /// Returns true if this is a mock environment
247 #[must_use]
248 pub fn is_mock(&self) -> bool {
249 matches!(self, Self::Mock) || (matches!(self, Self::Auto) && Self::detect().is_mock())
250 }
251
252 /// Returns true if this is a live environment
253 #[must_use]
254 pub fn is_live(&self) -> bool {
255 matches!(self, Self::Live) || (matches!(self, Self::Auto) && Self::detect().is_live())
256 }
257}
258
259/// Token management strategy trait for different token handling approaches
260#[async_trait]
261pub trait TokenStrategy: Send + Sync + std::fmt::Debug {
262 /// Gets a valid authentication token
263 async fn get_token(&self) -> Result<String, Error>;
264
265 /// Clears stored token (for testing)
266 async fn clear_token(&self) -> Result<(), Error>;
267
268 /// Returns whether this strategy should persist tokens
269 fn should_persist(&self) -> bool;
270
271 /// Returns the strategy type for debugging
272 fn strategy_type(&self) -> &'static str;
273
274 /// Returns self as Any for downcasting (used internally)
275 fn as_any(&self) -> &dyn std::any::Any;
276}
277
278/// Mock token strategy for isolated testing without persistence
279#[derive(Debug, Clone)]
280pub struct MockTokenStrategy {
281 token: String,
282}
283
284impl MockTokenStrategy {
285 /// Creates a new mock token strategy with the provided token
286 #[must_use]
287 pub const fn new(token: String) -> Self {
288 Self { token }
289 }
290
291 /// Creates a mock token strategy with a default test token
292 #[must_use]
293 pub fn with_default_token() -> Self {
294 Self::new("mock_token_default".to_string())
295 }
296
297 /// Creates a mock token strategy for a specific test case
298 #[must_use]
299 pub fn for_test(test_name: &str) -> Self {
300 Self::new(format!("mock_token_{test_name}"))
301 }
302}
303
304#[async_trait]
305impl TokenStrategy for MockTokenStrategy {
306 async fn get_token(&self) -> Result<String, Error> {
307 tracing::debug!("Using mock token strategy - returning pre-set token");
308 Ok(self.token.clone())
309 }
310
311 async fn clear_token(&self) -> Result<(), Error> {
312 tracing::debug!("Mock token strategy - clear_token is a no-op");
313 Ok(())
314 }
315
316 fn should_persist(&self) -> bool {
317 false
318 }
319
320 fn strategy_type(&self) -> &'static str {
321 "mock"
322 }
323
324 fn as_any(&self) -> &dyn std::any::Any {
325 self
326 }
327}
328
329/// Live token strategy that wraps the existing `TokenManager` for full token management
330#[derive(Debug)]
331pub struct LiveTokenStrategy {
332 token_manager: Arc<TokenManager>,
333}
334
335impl LiveTokenStrategy {
336 /// Creates a new live token strategy using the global `TokenManager` instance
337 ///
338 /// # Errors
339 /// Returns an error if the `TokenManager` cannot be initialized
340 pub async fn new() -> Result<Self, Error> {
341 let token_manager = TokenManager::get_global_instance().await?;
342 Ok(Self { token_manager })
343 }
344
345 /// Creates a new live token strategy with a custom `TokenManager`
346 #[must_use]
347 pub const fn with_token_manager(token_manager: Arc<TokenManager>) -> Self {
348 Self { token_manager }
349 }
350
351 /// Creates a live token strategy with custom retry configuration
352 ///
353 /// # Errors
354 /// Returns an error if the `TokenManager` cannot be initialized
355 pub async fn with_config(config: RetryConfig) -> Result<Self, Error> {
356 let base_url = get_amp_api_base_url()?;
357 let token_manager =
358 Arc::new(TokenManager::with_config_and_base_url(config, base_url).await?);
359 Ok(Self { token_manager })
360 }
361
362 /// Creates a live token strategy optimized for testing
363 ///
364 /// # Errors
365 /// Returns an error if the `TokenManager` cannot be initialized
366 pub async fn for_testing() -> Result<Self, Error> {
367 let config = RetryConfig::for_tests();
368 Self::with_config(config).await
369 }
370
371 /// Gets current token information for debugging and monitoring
372 ///
373 /// # Errors
374 /// Returns an error if token information retrieval fails
375 pub async fn get_token_info(&self) -> Result<Option<TokenInfo>, Error> {
376 self.token_manager.get_token_info().await
377 }
378}
379
380#[async_trait]
381impl TokenStrategy for LiveTokenStrategy {
382 async fn get_token(&self) -> Result<String, Error> {
383 tracing::debug!("Using live token strategy - full token management");
384 self.token_manager.get_token().await
385 }
386
387 async fn clear_token(&self) -> Result<(), Error> {
388 self.token_manager.clear_token().await
389 }
390
391 fn should_persist(&self) -> bool {
392 true
393 }
394
395 fn strategy_type(&self) -> &'static str {
396 "live"
397 }
398
399 fn as_any(&self) -> &dyn std::any::Any {
400 self
401 }
402}
403
404#[derive(Error, Debug)]
405pub enum Error {
406 #[error("Missing {0} environment variable")]
407 MissingEnvVar(String),
408 #[error("AMP request failed: {0}")]
409 RequestFailed(String),
410 #[error("AMP request failed\n\nMethod: {method}\nEndpoint: {endpoint}\nStatus: {status}\n\nError: {error_message}")]
411 RequestFailedDetailed {
412 /// The HTTP method used (GET, POST, etc.)
413 method: String,
414 /// The full endpoint URL that was called
415 endpoint: String,
416 /// The HTTP status code
417 status: reqwest::StatusCode,
418 /// The error message or response body
419 error_message: String,
420 },
421 #[error("Failed to parse AMP response: {0}")]
422 ResponseParsingFailed(String),
423 #[error("Failed to parse AMP response: {serde_error}\n\nMethod: {method}\nEndpoint: {endpoint}\nExpected Type: {expected_type}\n\nRaw Response:\n{raw_response}")]
424 ResponseDeserializationFailed {
425 /// The HTTP method used (GET, POST, etc.)
426 method: String,
427 /// The full endpoint URL that was called
428 endpoint: String,
429 /// The expected Rust type name
430 expected_type: String,
431 /// The original serde deserialization error message
432 serde_error: String,
433 /// The complete raw response body text that failed to parse
434 raw_response: String,
435 },
436 #[error("AMP token request failed with status {status}: {error_text}")]
437 TokenRequestFailed {
438 status: reqwest::StatusCode,
439 error_text: String,
440 },
441 #[error("Failed to parse url: {0}")]
442 UrlParse(#[from] url::ParseError),
443 #[error("Reqwest error: {0}")]
444 Reqwest(#[from] reqwest::Error),
445 #[error("Invalid retry configuration: {0}")]
446 InvalidRetryConfig(String),
447 #[error("Token management error: {0}")]
448 Token(#[from] TokenError),
449}
450
451/// Enhanced error enum for distribution operations and `ElementsRpc`
452#[derive(Error, Debug)]
453pub enum AmpError {
454 #[error("API error: {0}")]
455 Api(String),
456 #[error("API error\n\nEndpoint: {endpoint}\nMethod: {method}\n\nError: {error_message}")]
457 ApiDetailed {
458 /// The API endpoint that was called
459 endpoint: String,
460 /// The HTTP method used
461 method: String,
462 /// The error message
463 error_message: String,
464 },
465
466 #[error("RPC error: {0}")]
467 Rpc(String),
468 #[error("RPC error: {error_message}\n\nMethod: {rpc_method}\nParameters: {params}\n\nRaw Response:\n{raw_response}")]
469 RpcDetailed {
470 /// The RPC method name that was called
471 rpc_method: String,
472 /// The parameters passed to the RPC method
473 params: String,
474 /// The error message
475 error_message: String,
476 /// The complete raw response from the RPC server
477 raw_response: String,
478 },
479
480 #[error("Signer error: {0}")]
481 Signer(#[from] SignerError),
482
483 #[error("Timeout waiting for confirmations: {0}")]
484 Timeout(String),
485
486 #[error("Validation error: {0}")]
487 Validation(String),
488
489 #[error("Network error: {0}")]
490 Network(#[from] reqwest::Error),
491
492 #[error("Serialization error: {0}")]
493 Serialization(#[from] serde_json::Error),
494 #[error("Serialization error: {serde_error}\n\nOperation: {operation}\nData Type: {data_type}\n\nContext: {context}")]
495 SerializationDetailed {
496 /// The serialization operation (serialize/deserialize)
497 operation: String,
498 /// The data type being processed
499 data_type: String,
500 /// Additional context about the operation
501 context: String,
502 /// The original serde error message
503 serde_error: String,
504 },
505
506 #[error(transparent)]
507 Existing(#[from] Error),
508}
509
510impl AmpError {
511 /// Creates a new API error
512 pub fn api<S: Into<String>>(message: S) -> Self {
513 Self::Api(message.into())
514 }
515
516 /// Creates a new RPC error
517 pub fn rpc<S: Into<String>>(message: S) -> Self {
518 Self::Rpc(message.into())
519 }
520
521 /// Creates a new timeout error
522 pub fn timeout<S: Into<String>>(message: S) -> Self {
523 Self::Timeout(message.into())
524 }
525
526 /// Creates a new validation error
527 pub fn validation<S: Into<String>>(message: S) -> Self {
528 Self::Validation(message.into())
529 }
530
531 /// Adds context to an error
532 #[must_use]
533 pub fn with_context<S: Into<String>>(self, context: S) -> Self {
534 let context_str = context.into();
535 match self {
536 Self::Api(msg) => Self::Api(format!("{context_str}: {msg}")),
537 Self::ApiDetailed {
538 endpoint,
539 method,
540 error_message,
541 } => Self::ApiDetailed {
542 endpoint,
543 method,
544 error_message: format!("{context_str}: {error_message}"),
545 },
546 Self::Rpc(msg) => Self::Rpc(format!("{context_str}: {msg}")),
547 Self::RpcDetailed {
548 rpc_method,
549 params,
550 error_message,
551 raw_response,
552 } => Self::RpcDetailed {
553 rpc_method,
554 params,
555 error_message: format!("{context_str}: {error_message}"),
556 raw_response,
557 },
558 Self::Timeout(msg) => Self::Timeout(format!("{context_str}: {msg}")),
559 Self::Validation(msg) => Self::Validation(format!("{context_str}: {msg}")),
560 other => other, // Don't modify other error types
561 }
562 }
563
564 /// Returns true if this error indicates a retryable condition
565 #[must_use]
566 pub const fn is_retryable(&self) -> bool {
567 match self {
568 Self::Network(_) | Self::Rpc(_) | Self::RpcDetailed { .. } => true, // RPC errors might be transient
569 Self::Existing(Error::Token(token_err)) => token_err.is_retryable(),
570 _ => false,
571 }
572 }
573
574 /// Provides user-friendly retry instructions when applicable
575 #[must_use]
576 pub fn retry_instructions(&self) -> Option<String> {
577 match self {
578 Self::Network(_) => Some("Check network connection and retry".to_string()),
579 Self::Rpc(_) | Self::RpcDetailed { .. } => {
580 Some("Check Elements node connection and retry".to_string())
581 }
582 Self::Timeout(msg) if msg.contains("txid") => {
583 Some("Use the transaction ID to manually confirm the distribution".to_string())
584 }
585 Self::Existing(Error::Token(TokenError::RateLimited {
586 retry_after_seconds,
587 })) => Some(format!(
588 "Rate limited. Retry after {retry_after_seconds} seconds"
589 )),
590 _ => None,
591 }
592 }
593}
594
595/// Detailed error types for token management operations
596#[derive(Error, Debug, Clone, PartialEq, Eq)]
597pub enum TokenError {
598 #[error("Token refresh failed: {0}")]
599 RefreshFailed(String),
600 #[error("Token obtain failed after {attempts} attempts: {last_error}")]
601 ObtainFailed { attempts: u32, last_error: String },
602 #[error("Rate limited: retry after {retry_after_seconds} seconds")]
603 RateLimited { retry_after_seconds: u64 },
604 #[error("Request timeout after {timeout_seconds} seconds")]
605 Timeout { timeout_seconds: u64 },
606 #[error("Serialization error: {0}")]
607 Serialization(String),
608 #[error("Token storage error: {0}")]
609 Storage(String),
610 #[error("Token validation error: {0}")]
611 Validation(String),
612}
613
614impl TokenError {
615 /// Creates a new `RefreshFailed` error
616 #[must_use]
617 pub fn refresh_failed<S: Into<String>>(message: S) -> Self {
618 Self::RefreshFailed(message.into())
619 }
620
621 /// Creates a new `ObtainFailed` error
622 #[must_use]
623 pub const fn obtain_failed(attempts: u32, last_error: String) -> Self {
624 Self::ObtainFailed {
625 attempts,
626 last_error,
627 }
628 }
629
630 /// Creates a new `RateLimited` error
631 #[must_use]
632 pub const fn rate_limited(retry_after_seconds: u64) -> Self {
633 Self::RateLimited {
634 retry_after_seconds,
635 }
636 }
637
638 /// Creates a new Timeout error
639 #[must_use]
640 pub const fn timeout(timeout_seconds: u64) -> Self {
641 Self::Timeout { timeout_seconds }
642 }
643
644 /// Creates a new Serialization error
645 #[must_use]
646 pub fn serialization<S: Into<String>>(message: S) -> Self {
647 Self::Serialization(message.into())
648 }
649
650 /// Creates a new Storage error
651 #[must_use]
652 pub fn storage<S: Into<String>>(message: S) -> Self {
653 Self::Storage(message.into())
654 }
655
656 /// Creates a new Validation error
657 #[must_use]
658 pub fn validation<S: Into<String>>(message: S) -> Self {
659 Self::Validation(message.into())
660 }
661
662 /// Returns true if this error indicates a retryable condition
663 #[must_use]
664 pub const fn is_retryable(&self) -> bool {
665 matches!(
666 self,
667 Self::RefreshFailed(_) | Self::RateLimited { .. } | Self::Timeout { .. }
668 )
669 }
670
671 /// Returns true if this error indicates a rate limiting condition
672 #[must_use]
673 pub const fn is_rate_limited(&self) -> bool {
674 matches!(self, Self::RateLimited { .. })
675 }
676
677 /// Returns the retry delay in seconds if this is a rate limited error
678 #[must_use]
679 pub const fn retry_after_seconds(&self) -> Option<u64> {
680 match self {
681 Self::RateLimited {
682 retry_after_seconds,
683 } => Some(*retry_after_seconds),
684 _ => None,
685 }
686 }
687}
688
689// Conversion from serde_json::Error for serialization errors
690impl From<serde_json::Error> for TokenError {
691 fn from(err: serde_json::Error) -> Self {
692 Self::Serialization(err.to_string())
693 }
694}
695
696#[cfg(test)]
697mod amp_error_tests {
698 use super::*;
699
700 #[test]
701 fn test_amp_error_creation_helpers() {
702 let api_error = AmpError::api("Failed to create distribution");
703 assert!(matches!(api_error, AmpError::Api(_)));
704
705 let rpc_error = AmpError::rpc("Elements node connection failed");
706 assert!(matches!(rpc_error, AmpError::Rpc(_)));
707
708 let validation_error = AmpError::validation("Invalid asset UUID format");
709 assert!(matches!(validation_error, AmpError::Validation(_)));
710
711 let timeout_error = AmpError::timeout("Confirmation timeout");
712 assert!(matches!(timeout_error, AmpError::Timeout(_)));
713 }
714
715 #[test]
716 fn test_amp_error_with_context() {
717 let api_error = AmpError::api("Failed to create distribution");
718 let contextual_error = api_error.with_context("During distribution creation");
719
720 match contextual_error {
721 AmpError::Api(msg) => {
722 assert!(msg.contains("During distribution creation"));
723 assert!(msg.contains("Failed to create distribution"));
724 }
725 _ => panic!("Expected Api error variant"),
726 }
727
728 // Test that context doesn't modify errors that already have good context
729 let signer_error = AmpError::Signer(SignerError::Lwk("Test error".to_string()));
730 let contextual_signer = signer_error.with_context("Additional context");
731 assert!(matches!(contextual_signer, AmpError::Signer(_)));
732 }
733
734 #[test]
735 fn test_amp_error_retryability() {
736 let api_error = AmpError::api("Failed to create distribution");
737 assert!(!api_error.is_retryable());
738
739 let rpc_error = AmpError::rpc("Elements node connection failed");
740 assert!(rpc_error.is_retryable());
741
742 let validation_error = AmpError::validation("Invalid asset UUID format");
743 assert!(!validation_error.is_retryable());
744
745 let timeout_error = AmpError::timeout("Confirmation timeout");
746 assert!(!timeout_error.is_retryable());
747
748 let signer_error = AmpError::Signer(SignerError::Lwk("Test error".to_string()));
749 assert!(!signer_error.is_retryable());
750 }
751
752 #[test]
753 fn test_amp_error_retry_instructions() {
754 let rpc_error = AmpError::rpc("Elements node connection failed");
755 let instructions = rpc_error.retry_instructions();
756 assert!(instructions.is_some());
757 assert!(instructions.unwrap().contains("Elements node"));
758
759 let validation_error = AmpError::validation("Invalid asset UUID format");
760 assert!(validation_error.retry_instructions().is_none());
761
762 let timeout_with_txid = AmpError::timeout("Confirmation timeout for txid abc123");
763 let timeout_instructions = timeout_with_txid.retry_instructions();
764 assert!(timeout_instructions.is_some());
765 assert!(timeout_instructions.unwrap().contains("transaction ID"));
766 }
767
768 #[test]
769 fn test_amp_error_display() {
770 let api_error = AmpError::api("Test API error");
771 assert_eq!(format!("{}", api_error), "API error: Test API error");
772
773 let rpc_error = AmpError::rpc("Test RPC error");
774 assert_eq!(format!("{}", rpc_error), "RPC error: Test RPC error");
775
776 let validation_error = AmpError::validation("Test validation error");
777 assert_eq!(
778 format!("{}", validation_error),
779 "Validation error: Test validation error"
780 );
781
782 let timeout_error = AmpError::timeout("Test timeout error");
783 assert_eq!(
784 format!("{}", timeout_error),
785 "Timeout waiting for confirmations: Test timeout error"
786 );
787 }
788
789 #[test]
790 fn test_amp_error_from_conversions() {
791 // Test conversion from SignerError
792 let signer_error = SignerError::Lwk("Test LWK error".to_string());
793 let amp_error = AmpError::from(signer_error);
794 assert!(matches!(amp_error, AmpError::Signer(_)));
795
796 // Test conversion from existing Error
797 let existing_error = Error::MissingEnvVar("TEST_VAR".to_string());
798 let amp_error = AmpError::from(existing_error);
799 assert!(matches!(amp_error, AmpError::Existing(_)));
800
801 // Test conversion from serde_json::Error
802 let json_error = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
803 let amp_error = AmpError::from(json_error);
804 assert!(matches!(amp_error, AmpError::Serialization(_)));
805 }
806}
807
808/// Elements RPC client for blockchain operations
809#[derive(Debug)]
810pub struct ElementsRpc {
811 client: reqwest::Client,
812 base_url: String,
813 username: String,
814 password: String,
815}
816
817/// Network information from Elements node
818#[derive(Debug, serde::Deserialize)]
819pub struct NetworkInfo {
820 pub version: i64,
821 pub subversion: String,
822 pub protocolversion: i64,
823 pub localservices: String,
824 pub localrelay: bool,
825 pub timeoffset: i64,
826 pub networkactive: bool,
827 pub connections: i64,
828 pub networks: Vec<serde_json::Value>,
829 pub relayfee: f64,
830 pub incrementalfee: f64,
831 pub localaddresses: Vec<serde_json::Value>,
832 pub warnings: String,
833}
834
835/// Blockchain information from Elements node
836#[derive(Debug, serde::Deserialize)]
837pub struct BlockchainInfo {
838 pub chain: String,
839 pub blocks: i64,
840 pub headers: i64,
841 pub bestblockhash: String,
842 #[serde(default)]
843 pub difficulty: Option<f64>,
844 #[serde(default)]
845 pub mediantime: Option<i64>,
846 #[serde(default)]
847 pub verificationprogress: Option<f64>,
848 #[serde(default)]
849 pub initialblockdownload: Option<bool>,
850 #[serde(default)]
851 pub chainwork: Option<String>,
852 #[serde(default)]
853 pub size_on_disk: Option<i64>,
854 #[serde(default)]
855 pub pruned: Option<bool>,
856 #[serde(default)]
857 pub softforks: Option<serde_json::Value>,
858 #[serde(default)]
859 pub warnings: Option<String>,
860}
861
862/// RPC request structure for Elements node
863#[derive(Debug, serde::Serialize)]
864struct RpcRequest {
865 jsonrpc: String,
866 id: String,
867 method: String,
868 params: serde_json::Value,
869}
870
871/// RPC response structure from Elements node
872#[derive(Debug, serde::Deserialize)]
873struct RpcResponse<T> {
874 #[allow(dead_code)]
875 jsonrpc: Option<String>, // Optional for JSON-RPC 1.0 compatib
876 #[allow(dead_code)]
877 id: String,
878 result: Option<T>,
879 error: Option<RpcError>,
880}
881
882/// RPC error structure from Elements node
883#[derive(Debug, serde::Deserialize)]
884struct RpcError {
885 code: i32,
886 message: String,
887}
888
889impl ElementsRpc {
890 /// Creates a new `ElementsRpc` client with connection parameters
891 ///
892 /// # Arguments
893 /// * `url` - The RPC endpoint URL (e.g., <http://localhost:18884>)
894 /// * `username` - RPC authentication username
895 /// * `password` - RPC authentication password
896 ///
897 /// # Examples
898 /// ```
899 /// use amp_rs::ElementsRpc;
900 ///
901 /// let rpc = ElementsRpc::new(
902 /// "http://localhost:18884".to_string(),
903 /// "user".to_string(),
904 /// "pass".to_string()
905 /// );
906 /// ```
907 /// # Panics
908 ///
909 /// Panics if the HTTP client cannot be created.
910 #[must_use]
911 pub fn new(url: String, username: String, password: String) -> Self {
912 let client = reqwest::Client::builder()
913 .timeout(std::time::Duration::from_secs(30))
914 .build()
915 .expect("Failed to create HTTP client");
916
917 Self {
918 client,
919 base_url: url,
920 username,
921 password,
922 }
923 }
924
925 /// Creates a new `ElementsRpc` client from environment variables
926 ///
927 /// Expected environment variables:
928 /// - `ELEMENTS_RPC_URL`: RPC endpoint URL
929 /// - `ELEMENTS_RPC_USER`: RPC username
930 /// - `ELEMENTS_RPC_PASSWORD`: RPC password
931 ///
932 /// # Errors
933 /// Returns an error if any required environment variable is missing
934 ///
935 /// # Examples
936 /// ```no_run
937 /// use amp_rs::ElementsRpc;
938 ///
939 /// let rpc = ElementsRpc::from_env().unwrap();
940 /// ```
941 pub fn from_env() -> Result<Self, AmpError> {
942 let url = env::var("ELEMENTS_RPC_URL")
943 .map_err(|_| AmpError::validation("Missing ELEMENTS_RPC_URL environment variable"))?;
944 let username = env::var("ELEMENTS_RPC_USER")
945 .map_err(|_| AmpError::validation("Missing ELEMENTS_RPC_USER environment variable"))?;
946 let password = env::var("ELEMENTS_RPC_PASSWORD").map_err(|_| {
947 AmpError::validation("Missing ELEMENTS_RPC_PASSWORD environment variable")
948 })?;
949
950 Ok(Self::new(url, username, password))
951 }
952
953 /// Makes an RPC call to the Elements node
954 ///
955 /// # Arguments
956 /// * `method` - The RPC method name
957 /// * `params` - The parameters for the RPC call
958 ///
959 /// # Errors
960 /// Returns an error if the RPC call fails or returns an error
961 async fn rpc_call<T: serde::de::DeserializeOwned>(
962 &self,
963 method: &str,
964 params: serde_json::Value,
965 ) -> Result<T, AmpError> {
966 tracing::debug!("Making RPC call: {} with params: {:?}", method, params);
967
968 let request = RpcRequest {
969 jsonrpc: "1.0".to_string(),
970 id: "amp-client".to_string(),
971 method: method.to_string(),
972 params,
973 };
974
975 let response = self
976 .client
977 .post(&self.base_url)
978 .basic_auth(&self.username, Some(&self.password))
979 .json(&request)
980 .send()
981 .await
982 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
983
984 if !response.status().is_success() {
985 let status = response.status();
986 let error_body = response
987 .text()
988 .await
989 .unwrap_or_else(|_| "Unable to read error body".to_string());
990 return Err(AmpError::rpc(format!(
991 "RPC request failed with status: {status} - Body: {error_body}"
992 )));
993 }
994
995 let rpc_response: RpcResponse<T> = response
996 .json()
997 .await
998 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
999
1000 if let Some(error) = rpc_response.error {
1001 return Err(AmpError::rpc(format!(
1002 "RPC error {}: {}",
1003 error.code, error.message
1004 )));
1005 }
1006
1007 rpc_response
1008 .result
1009 .ok_or_else(|| AmpError::rpc("RPC response missing result field".to_string()))
1010 }
1011
1012 /// Retrieves network information from the Elements node
1013 ///
1014 /// # Errors
1015 /// Returns an error if the RPC call fails
1016 ///
1017 /// # Examples
1018 /// ```no_run
1019 /// # use amp_rs::ElementsRpc;
1020 /// # #[tokio::main]
1021 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1022 /// let rpc = ElementsRpc::from_env()?;
1023 /// let network_info = rpc.get_network_info().await?;
1024 /// println!("Node version: {}", network_info.version);
1025 /// # Ok(())
1026 /// # }
1027 /// ```
1028 pub async fn get_network_info(&self) -> Result<NetworkInfo, AmpError> {
1029 self.rpc_call("getnetworkinfo", serde_json::Value::Array(vec![]))
1030 .await
1031 }
1032
1033 /// Retrieves blockchain information from the Elements node
1034 ///
1035 /// # Errors
1036 /// Returns an error if the RPC call fails
1037 ///
1038 /// # Examples
1039 /// ```no_run
1040 /// # use amp_rs::ElementsRpc;
1041 /// # #[tokio::main]
1042 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1043 /// let rpc = ElementsRpc::from_env()?;
1044 /// let blockchain_info = rpc.get_blockchain_info().await?;
1045 /// println!("Current block height: {}", blockchain_info.blocks);
1046 /// # Ok(())
1047 /// # }
1048 /// ```
1049 pub async fn get_blockchain_info(&self) -> Result<BlockchainInfo, AmpError> {
1050 self.rpc_call("getblockchaininfo", serde_json::Value::Array(vec![]))
1051 .await
1052 }
1053
1054 /// Unlocks the wallet with a passphrase for the specified timeout
1055 ///
1056 /// # Arguments
1057 /// * `passphrase` - The wallet passphrase
1058 /// * `timeout` - Timeout in seconds for the unlock
1059 ///
1060 /// # Errors
1061 /// Returns an error if the RPC call fails
1062 ///
1063 /// # Examples
1064 /// ```no_run
1065 /// # use amp_rs::ElementsRpc;
1066 /// # #[tokio::main]
1067 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1068 /// let rpc = ElementsRpc::from_env()?;
1069 /// rpc.wallet_passphrase("my_passphrase", 300).await?;
1070 /// # Ok(())
1071 /// # }
1072 /// ```
1073 pub async fn wallet_passphrase(&self, passphrase: &str, timeout: u64) -> Result<(), AmpError> {
1074 let params = serde_json::json!([passphrase, timeout]);
1075
1076 // wallet_passphrase returns null on success, so we need to handle this specially
1077 let request = RpcRequest {
1078 jsonrpc: "1.0".to_string(),
1079 id: "amp-client".to_string(),
1080 method: "walletpassphrase".to_string(),
1081 params,
1082 };
1083
1084 let response = self
1085 .client
1086 .post(&self.base_url)
1087 .basic_auth(&self.username, Some(&self.password))
1088 .json(&request)
1089 .send()
1090 .await
1091 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1092
1093 if !response.status().is_success() {
1094 return Err(AmpError::rpc(format!(
1095 "RPC request failed with status: {}",
1096 response.status()
1097 )));
1098 }
1099
1100 let rpc_response: RpcResponse<serde_json::Value> = response
1101 .json()
1102 .await
1103 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1104
1105 if let Some(error) = rpc_response.error {
1106 return Err(AmpError::rpc(format!(
1107 "RPC error {}: {}",
1108 error.code, error.message
1109 )));
1110 }
1111
1112 // For wallet_passphrase, null result is success
1113 Ok(())
1114 }
1115
1116 /// Validates the connection to the Elements node
1117 ///
1118 /// This method performs basic connectivity and authentication checks by
1119 /// retrieving network information from the node.
1120 ///
1121 /// # Errors
1122 /// Returns an error if the connection validation fails
1123 ///
1124 /// # Examples
1125 /// ```no_run
1126 /// # use amp_rs::ElementsRpc;
1127 /// # #[tokio::main]
1128 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1129 /// let rpc = ElementsRpc::from_env()?;
1130 /// rpc.validate_connection().await?;
1131 /// println!("Connection to Elements node is valid");
1132 /// # Ok(())
1133 /// # }
1134 /// ```
1135 pub async fn validate_connection(&self) -> Result<(), AmpError> {
1136 tracing::info!(
1137 "Validating connection to Elements node at {}",
1138 self.base_url
1139 );
1140
1141 let network_info = self
1142 .get_network_info()
1143 .await
1144 .map_err(|e| e.with_context("Failed to validate Elements node connection"))?;
1145
1146 tracing::info!(
1147 "Successfully connected to Elements node - Version: {}, Connections: {}",
1148 network_info.version,
1149 network_info.connections
1150 );
1151
1152 Ok(())
1153 }
1154
1155 /// Retrieves comprehensive node status including network and blockchain information
1156 ///
1157 /// This method combines network and blockchain information to provide a complete
1158 /// status overview of the Elements node.
1159 ///
1160 /// # Errors
1161 /// Returns an error if any RPC call fails
1162 ///
1163 /// # Examples
1164 /// ```no_run
1165 /// # use amp_rs::ElementsRpc;
1166 /// # #[tokio::main]
1167 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1168 /// let rpc = ElementsRpc::from_env()?;
1169 /// let (network_info, blockchain_info) = rpc.get_node_status().await?;
1170 /// println!("Node version: {}, Block height: {}", network_info.version, blockchain_info.blocks);
1171 /// # Ok(())
1172 /// # }
1173 /// ```
1174 pub async fn get_node_status(&self) -> Result<(NetworkInfo, BlockchainInfo), AmpError> {
1175 let network_info = self.get_network_info().await?;
1176 let blockchain_info = self.get_blockchain_info().await?;
1177
1178 Ok((network_info, blockchain_info))
1179 }
1180
1181 /// Lists unspent transaction outputs (UTXOs) for a specific asset
1182 ///
1183 /// # Arguments
1184 /// * `asset_id` - Optional asset ID to filter UTXOs. If None, returns all UTXOs
1185 ///
1186 /// # Errors
1187 /// Returns an error if the RPC call fails
1188 ///
1189 /// # Panics
1190 /// May panic if `asset_id` is `Some` but the warning log message attempts to unwrap it.
1191 /// This is a known logging issue and does not affect normal operation.
1192 ///
1193 /// # Examples
1194 /// ```no_run
1195 /// # use amp_rs::ElementsRpc;
1196 /// # #[tokio::main]
1197 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1198 /// let rpc = ElementsRpc::from_env()?;
1199 /// let utxos = rpc.list_unspent(Some("asset_id_hex")).await?;
1200 /// println!("Found {} UTXOs", utxos.len());
1201 /// # Ok(())
1202 /// # }
1203 /// ```
1204 pub async fn list_unspent(&self, asset_id: Option<&str>) -> Result<Vec<Unspent>, AmpError> {
1205 tracing::debug!("Listing unspent outputs for asset: {:?}", asset_id);
1206
1207 let params = asset_id.map_or_else(
1208 || serde_json::json!([1, 9_999_999, [], true]),
1209 |asset| serde_json::json!([1, 9_999_999, [], true, {"asset": asset}]),
1210 );
1211
1212 let utxos: Vec<Unspent> = self
1213 .rpc_call("listunspent", params)
1214 .await
1215 .map_err(|e| {
1216 if let Some(asset) = asset_id {
1217 e.with_context(format!(
1218 "Failed to list unspent outputs for asset {asset}. \
1219 This may indicate that the treasury address is not imported in the Elements node. \
1220 Ensure the treasury address is properly imported as a watch-only address."
1221 ))
1222 } else {
1223 e.with_context("Failed to list unspent outputs")
1224 }
1225 })?;
1226
1227 tracing::debug!("Found {} unspent outputs", utxos.len());
1228
1229 // If we're looking for a specific asset and found no UTXOs, provide helpful context
1230 if utxos.is_empty() && asset_id.is_some() {
1231 tracing::warn!(
1232 "No UTXOs found for asset {}. This may indicate:\n\
1233 1. The treasury address is not imported in the Elements node\n\
1234 2. The asset issuance transaction hasn't been confirmed yet\n\
1235 3. The UTXOs have already been spent",
1236 asset_id.unwrap()
1237 );
1238 }
1239
1240 Ok(utxos)
1241 }
1242
1243 /// List unspent outputs for a specific wallet
1244 ///
1245 /// This method lists unspent transaction outputs (UTXOs) for a specific wallet,
1246 /// optionally filtered by asset ID.
1247 ///
1248 /// # Arguments
1249 ///
1250 /// * `wallet_name` - Name of the Elements wallet to query
1251 /// * `asset_id` - Optional asset ID to filter UTXOs by
1252 ///
1253 /// # Returns
1254 ///
1255 /// Returns a vector of unspent outputs
1256 ///
1257 /// # Errors
1258 /// Returns an error if the RPC call fails or the wallet cannot be loaded
1259 ///
1260 /// # Panics
1261 /// May panic when processing UTXO blinding data if scriptpubkey is unexpectedly missing.
1262 /// This should not occur under normal operation with valid Elements node responses.
1263 ///
1264 /// # Example
1265 ///
1266 /// ```no_run
1267 /// # use amp_rs::ElementsRpc;
1268 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1269 /// let rpc = ElementsRpc::from_env()?;
1270 /// // Note: This would need to be called in an async context
1271 /// // let utxos = rpc.list_unspent_for_wallet("test_wallet", None).await?;
1272 /// // println!("Found {} UTXOs", utxos.len());
1273 /// # Ok(())
1274 /// # }
1275 /// ```
1276 #[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
1277 pub async fn list_unspent_for_wallet(
1278 &self,
1279 wallet_name: &str,
1280 asset_id: Option<&str>,
1281 ) -> Result<Vec<Unspent>, AmpError> {
1282 tracing::debug!(
1283 "Listing unspent outputs for wallet {} and asset: {:?}",
1284 wallet_name,
1285 asset_id
1286 );
1287
1288 // First load the wallet to ensure it's available
1289 self.load_wallet(wallet_name).await?;
1290
1291 let params = asset_id.map_or_else(
1292 || serde_json::json!([1, 9_999_999, [], true]),
1293 |asset| serde_json::json!([1, 9_999_999, [], true, {"asset": asset}]),
1294 );
1295
1296 let request = RpcRequest {
1297 jsonrpc: "1.0".to_string(),
1298 id: "amp-client".to_string(),
1299 method: "listunspent".to_string(),
1300 params,
1301 };
1302
1303 // Use the wallet-specific RPC endpoint
1304 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1305
1306 let response = self
1307 .client
1308 .post(&wallet_url)
1309 .basic_auth(&self.username, Some(&self.password))
1310 .json(&request)
1311 .send()
1312 .await
1313 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1314
1315 if !response.status().is_success() {
1316 let status = response.status();
1317 let error_body = response
1318 .text()
1319 .await
1320 .unwrap_or_else(|_| "Unable to read error body".to_string());
1321 return Err(AmpError::rpc(format!(
1322 "RPC request failed with status: {status} - Body: {error_body}"
1323 )));
1324 }
1325
1326 let rpc_response: RpcResponse<Vec<Unspent>> = response
1327 .json()
1328 .await
1329 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1330
1331 if let Some(error) = rpc_response.error {
1332 return Err(AmpError::rpc(format!(
1333 "RPC error listing unspent outputs: {} (code: {})",
1334 error.message, error.code
1335 )));
1336 }
1337
1338 let mut utxos = rpc_response.result.unwrap_or_default();
1339
1340 // Enrich UTXOs with scriptpubkey information if missing
1341 for utxo in &mut utxos {
1342 if utxo.scriptpubkey.is_none() {
1343 tracing::debug!(
1344 "UTXO {}:{} missing scriptpubkey, attempting to derive from address",
1345 utxo.txid,
1346 utxo.vout
1347 );
1348
1349 // Try to derive scriptpubkey from the address
1350 if let Ok(address) = elements::Address::from_str(&utxo.address) {
1351 let script_pubkey = address.script_pubkey();
1352 utxo.scriptpubkey = Some(hex::encode(script_pubkey.as_bytes()));
1353 tracing::info!(
1354 "Derived scriptpubkey for UTXO {}:{} from address {}: {}",
1355 utxo.txid,
1356 utxo.vout,
1357 utxo.address,
1358 utxo.scriptpubkey.as_ref().unwrap()
1359 );
1360 } else {
1361 tracing::error!(
1362 "Failed to parse address {} for UTXO {}:{}",
1363 utxo.address,
1364 utxo.txid,
1365 utxo.vout
1366 );
1367
1368 // Fallback: try to get transaction details
1369 match self.get_transaction(&utxo.txid).await {
1370 Ok(tx_detail) => {
1371 tracing::debug!(
1372 "Retrieved transaction details for {} as fallback",
1373 utxo.txid
1374 );
1375 // Parse the transaction hex to extract the scriptpubkey for this output
1376 match hex::decode(&tx_detail.hex) {
1377 Ok(tx_bytes) => {
1378 match elements::Transaction::consensus_decode(&tx_bytes[..]) {
1379 Ok(tx) => {
1380 if let Some(output) = tx.output.get(utxo.vout as usize)
1381 {
1382 utxo.scriptpubkey = Some(hex::encode(
1383 output.script_pubkey.as_bytes(),
1384 ));
1385 tracing::info!("Enriched UTXO {}:{} with scriptpubkey from transaction: {}",
1386 utxo.txid, utxo.vout, utxo.scriptpubkey.as_ref().unwrap());
1387 } else {
1388 tracing::error!(
1389 "Output {} not found in transaction {}",
1390 utxo.vout,
1391 utxo.txid
1392 );
1393 }
1394 }
1395 Err(e) => {
1396 tracing::error!(
1397 "Failed to decode transaction {}: {}",
1398 utxo.txid,
1399 e
1400 );
1401 }
1402 }
1403 }
1404 Err(e) => {
1405 tracing::error!(
1406 "Failed to decode hex for transaction {}: {}",
1407 utxo.txid,
1408 e
1409 );
1410 }
1411 }
1412 }
1413 Err(e) => {
1414 tracing::error!(
1415 "Failed to get transaction details for {}: {}",
1416 utxo.txid,
1417 e
1418 );
1419 }
1420 }
1421 }
1422 } else {
1423 tracing::debug!("UTXO {}:{} already has scriptpubkey", utxo.txid, utxo.vout);
1424 }
1425 }
1426
1427 tracing::debug!(
1428 "Found {} unspent outputs for wallet {}",
1429 utxos.len(),
1430 wallet_name
1431 );
1432
1433 // If we're looking for a specific asset and found no UTXOs, provide helpful context
1434 if utxos.is_empty() && asset_id.is_some() {
1435 tracing::warn!(
1436 "No UTXOs found for asset {} in wallet {}. This may indicate:\n\
1437 1. The asset issuance transaction hasn't been confirmed yet\n\
1438 2. The UTXOs have already been spent\n\
1439 3. The wallet doesn't contain the expected addresses",
1440 asset_id.unwrap(),
1441 wallet_name
1442 );
1443 }
1444
1445 Ok(utxos)
1446 }
1447
1448 /// Creates a raw transaction with the specified inputs and outputs
1449 ///
1450 /// # Arguments
1451 /// * `inputs` - Vector of transaction inputs (UTXOs to spend)
1452 /// * `outputs` - Map of addresses to amounts for regular outputs
1453 /// * `assets` - Map of addresses to asset IDs for Liquid-specific outputs
1454 ///
1455 /// # Errors
1456 /// Returns an error if the RPC call fails or transaction creation fails
1457 ///
1458 /// # Examples
1459 /// ```no_run
1460 /// # use amp_rs::{ElementsRpc, model::{TxInput}};
1461 /// # use std::collections::HashMap;
1462 /// # #[tokio::main]
1463 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1464 /// let rpc = ElementsRpc::from_env()?;
1465 /// let inputs = vec![TxInput {
1466 /// txid: "abc123".to_string(),
1467 /// vout: 0,
1468 /// sequence: None,
1469 /// }];
1470 /// let mut outputs = HashMap::new();
1471 /// outputs.insert("address1".to_string(), 100.0);
1472 /// let mut assets = HashMap::new();
1473 /// assets.insert("address1".to_string(), "asset_id".to_string());
1474 /// let raw_tx = rpc.create_raw_transaction(inputs, outputs, assets).await?;
1475 /// # Ok(())
1476 /// # }
1477 /// ```
1478 #[allow(clippy::cognitive_complexity)]
1479 pub async fn create_raw_transaction(
1480 &self,
1481 inputs: Vec<TxInput>,
1482 outputs: std::collections::HashMap<String, f64>,
1483 assets: std::collections::HashMap<String, String>,
1484 ) -> Result<String, AmpError> {
1485 tracing::debug!(
1486 "Creating raw transaction with {} inputs and {} outputs",
1487 inputs.len(),
1488 outputs.len()
1489 );
1490
1491 // Elements RPC createrawtransaction expects:
1492 // createrawtransaction inputs outputs locktime replaceable assets
1493 let params = serde_json::json!([
1494 inputs, // inputs as TxInput array
1495 outputs, // outputs as address->amount map
1496 0, // locktime (0 = no locktime)
1497 false, // replaceable (false = not replaceable)
1498 assets // assets as address->asset_id map
1499 ]);
1500
1501 // Debug: Log the exact parameters being sent to createrawtransaction
1502 tracing::error!("createrawtransaction parameters:");
1503 tracing::error!(
1504 " inputs: {}",
1505 serde_json::to_string_pretty(&inputs).unwrap_or_default()
1506 );
1507 tracing::error!(
1508 " outputs: {}",
1509 serde_json::to_string_pretty(&outputs).unwrap_or_default()
1510 );
1511 tracing::error!(
1512 " assets: {}",
1513 serde_json::to_string_pretty(&assets).unwrap_or_default()
1514 );
1515
1516 let raw_tx: String = self
1517 .rpc_call("createrawtransaction", params)
1518 .await
1519 .map_err(|e| {
1520 tracing::error!("createrawtransaction RPC call failed: {}", e);
1521 e.with_context("Failed to create raw transaction")
1522 })?;
1523
1524 tracing::debug!("Created raw transaction: {}", raw_tx);
1525 Ok(raw_tx)
1526 }
1527
1528 /// Imports an address into a specific wallet as watch-only
1529 ///
1530 /// # Arguments
1531 /// * `wallet_name` - Name of the wallet to import into
1532 /// * `address` - The address to import
1533 /// * `label` - Optional label for the address
1534 /// * `rescan` - Whether to rescan the blockchain for transactions
1535 ///
1536 /// # Errors
1537 /// Returns an error if the RPC call fails
1538 async fn import_address_to_wallet(
1539 &self,
1540 wallet_name: &str,
1541 address: &str,
1542 label: Option<&str>,
1543 rescan: bool,
1544 ) -> Result<(), AmpError> {
1545 tracing::debug!("Importing address {} into wallet {}", address, wallet_name);
1546
1547 // First load the wallet to ensure it's available
1548 self.load_wallet(wallet_name).await?;
1549
1550 let params = serde_json::json!([address, label.unwrap_or(""), rescan]);
1551
1552 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1553
1554 let request = RpcRequest {
1555 jsonrpc: "1.0".to_string(),
1556 id: "amp-client".to_string(),
1557 method: "importaddress".to_string(),
1558 params,
1559 };
1560
1561 let response = self
1562 .client
1563 .post(&wallet_url)
1564 .basic_auth(&self.username, Some(&self.password))
1565 .json(&request)
1566 .send()
1567 .await
1568 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1569
1570 if !response.status().is_success() {
1571 let status = response.status();
1572 let error_body = response
1573 .text()
1574 .await
1575 .unwrap_or_else(|_| "Unable to read error body".to_string());
1576 return Err(AmpError::rpc(format!(
1577 "RPC request failed with status: {status} - Body: {error_body}"
1578 )));
1579 }
1580
1581 let rpc_response: RpcResponse<serde_json::Value> = response
1582 .json()
1583 .await
1584 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1585
1586 if let Some(error) = rpc_response.error {
1587 // Ignore "already imported" errors
1588 if error.code != -4 {
1589 return Err(AmpError::rpc(format!(
1590 "RPC error importing address: {} (code: {})",
1591 error.message, error.code
1592 )));
1593 }
1594 }
1595
1596 tracing::debug!(
1597 "Successfully imported address {} into wallet {}",
1598 address,
1599 wallet_name
1600 );
1601 Ok(())
1602 }
1603
1604 /// Creates a raw transaction using a specific wallet context
1605 ///
1606 /// This method uses the wallet-specific RPC endpoint which is necessary
1607 /// for confidential transactions that require wallet context for blinding keys.
1608 ///
1609 /// # Arguments
1610 /// * `wallet_name` - Name of the wallet to use for transaction creation
1611 /// * `inputs` - Transaction inputs
1612 /// * `outputs` - Map of addresses to amounts
1613 /// * `assets` - Map of addresses to asset IDs
1614 ///
1615 /// # Returns
1616 /// Returns the raw transaction hex
1617 ///
1618 /// # Errors
1619 /// Returns an error if the RPC call fails
1620 #[allow(dead_code)]
1621 #[allow(clippy::cognitive_complexity)]
1622 async fn create_raw_transaction_with_wallet(
1623 &self,
1624 wallet_name: &str,
1625 inputs: Vec<TxInput>,
1626 outputs: std::collections::HashMap<String, f64>,
1627 assets: std::collections::HashMap<String, String>,
1628 ) -> Result<String, AmpError> {
1629 tracing::debug!(
1630 "Creating raw transaction with wallet {} - {} inputs and {} outputs",
1631 wallet_name,
1632 inputs.len(),
1633 outputs.len()
1634 );
1635
1636 // First load the wallet to ensure it's available
1637 self.load_wallet(wallet_name).await?;
1638
1639 // Elements RPC createrawtransaction expects outputs as an array of objects
1640 // Each output object should contain both address, amount, and asset
1641 let mut outputs_array = Vec::new();
1642
1643 for (address, amount) in &outputs {
1644 let asset_id = assets.get(address).ok_or_else(|| {
1645 AmpError::validation(format!("No asset ID found for address {address}"))
1646 })?;
1647
1648 // Convert amount to string with proper precision for Elements
1649 let amount_str = format!("{amount:.8}");
1650
1651 outputs_array.push(serde_json::json!({
1652 address.clone(): amount_str,
1653 "asset": asset_id
1654 }));
1655 }
1656
1657 let params = serde_json::json!([
1658 inputs, // inputs as TxInput array
1659 outputs_array, // outputs as array of {address: amount, asset: id} objects
1660 0, // locktime (0 = no locktime)
1661 false, // replaceable (false = not replaceable)
1662 ]);
1663
1664 // Debug: Log the exact parameters being sent to createrawtransaction
1665 tracing::error!("createrawtransaction parameters (wallet-specific, corrected format):");
1666 tracing::error!(" wallet: {}", wallet_name);
1667 tracing::error!(
1668 " inputs: {}",
1669 serde_json::to_string_pretty(&inputs).unwrap_or_default()
1670 );
1671 tracing::error!(
1672 " outputs_array: {}",
1673 serde_json::to_string_pretty(&outputs_array).unwrap_or_default()
1674 );
1675
1676 // Use the wallet-specific RPC endpoint
1677 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1678
1679 let request = RpcRequest {
1680 jsonrpc: "1.0".to_string(),
1681 id: "amp-client".to_string(),
1682 method: "createrawtransaction".to_string(),
1683 params,
1684 };
1685
1686 let response = self
1687 .client
1688 .post(&wallet_url)
1689 .basic_auth(&self.username, Some(&self.password))
1690 .json(&request)
1691 .send()
1692 .await
1693 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1694
1695 if !response.status().is_success() {
1696 let status = response.status();
1697 let error_body = response
1698 .text()
1699 .await
1700 .unwrap_or_else(|_| "Unable to read error body".to_string());
1701 return Err(AmpError::rpc(format!(
1702 "RPC request failed with status: {status} - Body: {error_body}"
1703 )));
1704 }
1705
1706 let rpc_response: RpcResponse<String> = response
1707 .json()
1708 .await
1709 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1710
1711 if let Some(error) = rpc_response.error {
1712 return Err(AmpError::rpc(format!(
1713 "RPC error creating raw transaction: {} (code: {})",
1714 error.message, error.code
1715 )));
1716 }
1717
1718 let raw_tx = rpc_response
1719 .result
1720 .ok_or_else(|| AmpError::rpc("No raw transaction returned".to_string()))?;
1721
1722 tracing::debug!(
1723 "Created raw transaction with wallet {}: {}",
1724 wallet_name,
1725 raw_tx
1726 );
1727 Ok(raw_tx)
1728 }
1729
1730 /// Imports an address into a specific wallet as watch-only
1731 ///
1732 /// # Arguments
1733 /// * `wallet_name` - Name of the wallet
1734 /// * `address` - The address to import
1735 /// * `label` - Optional label for the address
1736 /// * `rescan` - Optional whether to rescan the blockchain (default: false)
1737 ///
1738 /// # Errors
1739 /// Returns an error if the RPC call fails
1740 ///
1741 /// # Examples
1742 /// ```no_run
1743 /// # use amp_rs::ElementsRpc;
1744 /// # #[tokio::main]
1745 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1746 /// let rpc = ElementsRpc::from_env()?;
1747 /// rpc.import_address("my_wallet", "vjU8L4dKa1XyyVcPqKBbTgjT1tRC7qYp5VJGwndZSCFk4ntpWey1pQe6hcSGDMVurr9CsZ21EGsqGjWA", Some("test_address"), Some(false)).await?;
1748 /// # Ok(())
1749 /// # }
1750 /// ```
1751 pub async fn import_address(
1752 &self,
1753 wallet_name: &str,
1754 address: &str,
1755 label: Option<&str>,
1756 rescan: Option<bool>,
1757 ) -> Result<(), AmpError> {
1758 let rescan_value = rescan.unwrap_or(false);
1759 tracing::debug!(
1760 "Importing address: {} into wallet: {} with label: {:?}, rescan: {}",
1761 address,
1762 wallet_name,
1763 label,
1764 rescan_value
1765 );
1766
1767 let params = serde_json::json!([address, label.unwrap_or(""), rescan_value]);
1768
1769 // importaddress returns null on success
1770 let request = RpcRequest {
1771 jsonrpc: "1.0".to_string(),
1772 id: "amp-client".to_string(),
1773 method: "importaddress".to_string(),
1774 params,
1775 };
1776
1777 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1778
1779 let response = self
1780 .client
1781 .post(&wallet_url)
1782 .basic_auth(&self.username, Some(&self.password))
1783 .json(&request)
1784 .send()
1785 .await
1786 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1787
1788 if !response.status().is_success() {
1789 return Err(AmpError::rpc(format!(
1790 "RPC request failed with status: {}",
1791 response.status()
1792 )));
1793 }
1794
1795 let rpc_response: RpcResponse<serde_json::Value> = response
1796 .json()
1797 .await
1798 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1799
1800 if let Some(error) = rpc_response.error {
1801 return Err(AmpError::rpc(format!(
1802 "RPC error {}: {}",
1803 error.code, error.message
1804 )));
1805 }
1806
1807 tracing::debug!(
1808 "Successfully imported address: {} into wallet: {}",
1809 address,
1810 wallet_name
1811 );
1812 Ok(())
1813 }
1814
1815 /// Rescans the blockchain for a wallet
1816 ///
1817 /// # Arguments
1818 /// * `wallet_name` - Name of the wallet to rescan
1819 /// * `start_height` - Optional start height for rescan (default: 0)
1820 ///
1821 /// # Errors
1822 /// Returns an error if the RPC call fails
1823 ///
1824 /// # Examples
1825 /// ```no_run
1826 /// # use amp_rs::ElementsRpc;
1827 /// # #[tokio::main]
1828 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1829 /// let rpc = ElementsRpc::from_env()?;
1830 /// let result = rpc.rescan_blockchain("my_wallet", None).await?;
1831 /// # Ok(())
1832 /// # }
1833 /// ```
1834 pub async fn rescan_blockchain(
1835 &self,
1836 wallet_name: &str,
1837 start_height: Option<u64>,
1838 ) -> Result<serde_json::Value, AmpError> {
1839 tracing::debug!("Rescanning blockchain for wallet: {}", wallet_name);
1840
1841 let params = start_height.map_or_else(
1842 || serde_json::json!([]),
1843 |height| serde_json::json!([height]),
1844 );
1845
1846 let request = RpcRequest {
1847 jsonrpc: "1.0".to_string(),
1848 id: "amp-client".to_string(),
1849 method: "rescanblockchain".to_string(),
1850 params,
1851 };
1852
1853 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1854
1855 let response = self
1856 .client
1857 .post(&wallet_url)
1858 .basic_auth(&self.username, Some(&self.password))
1859 .json(&request)
1860 .send()
1861 .await
1862 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1863
1864 if !response.status().is_success() {
1865 return Err(AmpError::rpc(format!(
1866 "RPC request failed with status: {}",
1867 response.status()
1868 )));
1869 }
1870
1871 let rpc_response: RpcResponse<serde_json::Value> = response
1872 .json()
1873 .await
1874 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1875
1876 if let Some(error) = rpc_response.error {
1877 return Err(AmpError::rpc(format!(
1878 "RPC error rescanning blockchain: {} (code: {})",
1879 error.message, error.code
1880 )));
1881 }
1882
1883 let result = rpc_response
1884 .result
1885 .ok_or_else(|| AmpError::rpc("No result returned from rescanblockchain".to_string()))?;
1886
1887 tracing::debug!(
1888 "Successfully rescanned blockchain for wallet: {}",
1889 wallet_name
1890 );
1891 Ok(result)
1892 }
1893
1894 /// Creates or loads a wallet
1895 ///
1896 /// # Arguments
1897 /// * `wallet_name` - Name of the wallet to create or load
1898 /// * `disable_private_keys` - Whether to disable private keys (watch-only wallet)
1899 ///
1900 /// # Errors
1901 /// Returns an error if the RPC call fails
1902 ///
1903 /// # Examples
1904 /// ```no_run
1905 /// # use amp_rs::ElementsRpc;
1906 /// # #[tokio::main]
1907 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1908 /// let rpc = ElementsRpc::from_env()?;
1909 /// rpc.create_wallet("test_wallet", true).await?;
1910 /// # Ok(())
1911 /// # }
1912 /// ```
1913 pub async fn create_wallet(
1914 &self,
1915 wallet_name: &str,
1916 disable_private_keys: bool,
1917 ) -> Result<(), AmpError> {
1918 tracing::debug!(
1919 "Creating wallet: {} with disable_private_keys: {}",
1920 wallet_name,
1921 disable_private_keys
1922 );
1923
1924 let params = serde_json::json!([wallet_name, disable_private_keys]);
1925
1926 let request = RpcRequest {
1927 jsonrpc: "1.0".to_string(),
1928 id: "amp-client".to_string(),
1929 method: "createwallet".to_string(),
1930 params,
1931 };
1932
1933 let response = self
1934 .client
1935 .post(&self.base_url)
1936 .basic_auth(&self.username, Some(&self.password))
1937 .json(&request)
1938 .send()
1939 .await
1940 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1941
1942 if !response.status().is_success() {
1943 return Err(AmpError::rpc(format!(
1944 "RPC request failed with status: {}",
1945 response.status()
1946 )));
1947 }
1948
1949 let rpc_response: RpcResponse<serde_json::Value> = response
1950 .json()
1951 .await
1952 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1953
1954 if let Some(error) = rpc_response.error {
1955 // Ignore "wallet already exists" error
1956 if error.code != -4 {
1957 return Err(AmpError::rpc(format!(
1958 "RPC error {}: {}",
1959 error.code, error.message
1960 )));
1961 }
1962 tracing::debug!("Wallet {} already exists", wallet_name);
1963 } else {
1964 tracing::debug!("Successfully created wallet: {}", wallet_name);
1965 }
1966
1967 Ok(())
1968 }
1969
1970 /// Loads an existing wallet
1971 ///
1972 /// # Arguments
1973 /// * `wallet_name` - Name of the wallet to load
1974 ///
1975 /// # Errors
1976 /// Returns an error if the RPC call fails
1977 ///
1978 /// # Examples
1979 /// ```no_run
1980 /// # use amp_rs::ElementsRpc;
1981 /// # #[tokio::main]
1982 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1983 /// let rpc = ElementsRpc::from_env()?;
1984 /// rpc.load_wallet("test_wallet").await?;
1985 /// # Ok(())
1986 /// # }
1987 /// ```
1988 #[allow(clippy::cognitive_complexity)]
1989 pub async fn load_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
1990 tracing::debug!("Loading wallet: {}", wallet_name);
1991
1992 let params = serde_json::json!([wallet_name]);
1993
1994 let request = RpcRequest {
1995 jsonrpc: "1.0".to_string(),
1996 id: "amp-client".to_string(),
1997 method: "loadwallet".to_string(),
1998 params,
1999 };
2000
2001 let response = self
2002 .client
2003 .post(&self.base_url)
2004 .basic_auth(&self.username, Some(&self.password))
2005 .json(&request)
2006 .send()
2007 .await
2008 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
2009
2010 if !response.status().is_success() {
2011 let status = response.status();
2012 let error_body = response
2013 .text()
2014 .await
2015 .unwrap_or_else(|_| "Unable to read error body".to_string());
2016 tracing::debug!(
2017 "Load wallet failed with status: {} - Body: {}",
2018 status,
2019 error_body
2020 );
2021
2022 // For wallet loading, we want to be more permissive with errors
2023 // since the wallet might already be loaded
2024 if status == 500 && error_body.contains("already loaded") {
2025 tracing::debug!(
2026 "Wallet {} appears to already be loaded (500 error)",
2027 wallet_name
2028 );
2029 return Ok(());
2030 }
2031
2032 return Err(AmpError::rpc(format!(
2033 "RPC request failed with status: {status} - Body: {error_body}"
2034 )));
2035 }
2036
2037 let rpc_response: RpcResponse<serde_json::Value> = response
2038 .json()
2039 .await
2040 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
2041
2042 if let Some(error) = rpc_response.error {
2043 // Ignore "wallet already loaded" error
2044 if error.code != -35 {
2045 return Err(AmpError::rpc(format!(
2046 "RPC error {}: {}",
2047 error.code, error.message
2048 )));
2049 }
2050 tracing::debug!("Wallet {} already loaded", wallet_name);
2051 } else {
2052 tracing::debug!("Successfully loaded wallet: {}", wallet_name);
2053 }
2054
2055 Ok(())
2056 }
2057
2058 /// Unloads a wallet
2059 ///
2060 /// # Arguments
2061 /// * `wallet_name` - Name of the wallet to unload
2062 ///
2063 /// # Errors
2064 /// Returns an error if the RPC call fails
2065 ///
2066 /// # Examples
2067 /// ```no_run
2068 /// # use amp_rs::ElementsRpc;
2069 /// # #[tokio::main]
2070 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2071 /// let rpc = ElementsRpc::from_env()?;
2072 /// rpc.unload_wallet("test_wallet").await?;
2073 /// # Ok(())
2074 /// # }
2075 /// ```
2076 pub async fn unload_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
2077 tracing::debug!("Unloading wallet: {}", wallet_name);
2078
2079 let params = serde_json::json!([wallet_name]);
2080
2081 let request = RpcRequest {
2082 jsonrpc: "1.0".to_string(),
2083 id: "amp-client".to_string(),
2084 method: "unloadwallet".to_string(),
2085 params,
2086 };
2087
2088 let response = self
2089 .client
2090 .post(&self.base_url)
2091 .basic_auth(&self.username, Some(&self.password))
2092 .json(&request)
2093 .send()
2094 .await
2095 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
2096
2097 if !response.status().is_success() {
2098 return Err(AmpError::rpc(format!(
2099 "RPC request failed with status: {}",
2100 response.status()
2101 )));
2102 }
2103
2104 let rpc_response: RpcResponse<serde_json::Value> = response
2105 .json()
2106 .await
2107 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
2108
2109 if let Some(error) = rpc_response.error {
2110 return Err(AmpError::rpc(format!(
2111 "RPC error {}: {}",
2112 error.code, error.message
2113 )));
2114 }
2115
2116 tracing::debug!("Successfully unloaded wallet: {}", wallet_name);
2117 Ok(())
2118 }
2119
2120 /// Lists all available wallets
2121 ///
2122 /// # Errors
2123 /// Returns an error if the RPC call fails
2124 ///
2125 /// # Examples
2126 /// ```no_run
2127 /// # use amp_rs::ElementsRpc;
2128 /// # #[tokio::main]
2129 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2130 /// let rpc = ElementsRpc::from_env()?;
2131 /// let wallets = rpc.list_wallets().await?;
2132 /// println!("Available wallets: {:?}", wallets);
2133 /// # Ok(())
2134 /// # }
2135 /// ```
2136 pub async fn list_wallets(&self) -> Result<Vec<String>, AmpError> {
2137 tracing::debug!("Listing available wallets");
2138
2139 let params = serde_json::json!([]);
2140
2141 let wallets: Vec<String> = self
2142 .rpc_call("listwallets", params)
2143 .await
2144 .map_err(|e| e.with_context("Failed to list wallets"))?;
2145
2146 tracing::debug!("Found {} wallets", wallets.len());
2147 Ok(wallets)
2148 }
2149
2150 /// Sets up a watch-only wallet with the given address
2151 ///
2152 /// This is a convenience method that creates a watch-only wallet and imports the address
2153 ///
2154 /// # Arguments
2155 /// * `wallet_name` - Name of the wallet to create
2156 /// * `address` - Address to import as watch-only
2157 /// * `label` - Optional label for the address
2158 ///
2159 /// # Errors
2160 /// Returns an error if wallet creation or address import fails
2161 ///
2162 /// # Examples
2163 /// ```no_run
2164 /// # use amp_rs::ElementsRpc;
2165 /// # #[tokio::main]
2166 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2167 /// let rpc = ElementsRpc::from_env()?;
2168 /// rpc.setup_watch_only_wallet("test_wallet", "vjU8L4dKa1XyyVcPqKBbTgjT1tRC7qYp5VJGwndZSCFk4ntpWey1pQe6hcSGDMVurr9CsZ21EGsqGjWA", Some("treasury")).await?;
2169 /// # Ok(())
2170 /// # }
2171 /// ```
2172 #[allow(clippy::cognitive_complexity)]
2173 pub async fn setup_watch_only_wallet(
2174 &self,
2175 wallet_name: &str,
2176 address: &str,
2177 label: Option<&str>,
2178 ) -> Result<(), AmpError> {
2179 tracing::info!(
2180 "Setting up watch-only wallet '{}' with address: {}",
2181 wallet_name,
2182 address
2183 );
2184
2185 // Try the full wallet setup approach first
2186 match self
2187 .setup_wallet_with_address(wallet_name, address, label)
2188 .await
2189 {
2190 Ok(()) => {
2191 tracing::info!(
2192 "Successfully set up watch-only wallet '{}' with address: {}",
2193 wallet_name,
2194 address
2195 );
2196 return Ok(());
2197 }
2198 Err(e) => {
2199 tracing::warn!(
2200 "Full wallet setup failed: {}, trying direct address import",
2201 e
2202 );
2203 }
2204 }
2205
2206 // Fallback: Try to import the address directly without wallet operations
2207 match self.import_address_direct(address, label) {
2208 Ok(()) => {
2209 tracing::info!("Successfully imported address directly: {}", address);
2210 Ok(())
2211 }
2212 Err(e) => {
2213 tracing::error!(
2214 "Both wallet setup and direct import failed for address: {}",
2215 address
2216 );
2217 Err(AmpError::rpc(format!(
2218 "Failed to set up watch-only wallet or import address: wallet setup error: {e}, direct import error: {e}"
2219 )))
2220 }
2221 }
2222 }
2223
2224 /// Attempts to set up a wallet with address using the standard approach
2225 async fn setup_wallet_with_address(
2226 &self,
2227 wallet_name: &str,
2228 address: &str,
2229 label: Option<&str>,
2230 ) -> Result<(), AmpError> {
2231 // Try to create the wallet (will ignore if it already exists)
2232 self.create_wallet(wallet_name, true).await?;
2233
2234 // Try to load the wallet (will ignore if already loaded)
2235 self.load_wallet(wallet_name).await?;
2236
2237 // Import the address without rescanning (for faster setup)
2238 self.import_address(wallet_name, address, label, Some(false))
2239 .await?;
2240
2241 Ok(())
2242 }
2243
2244 /// Attempts to import an address directly without wallet operations (uses default wallet)
2245 #[allow(clippy::unused_self)]
2246 fn import_address_direct(&self, address: &str, _label: Option<&str>) -> Result<(), AmpError> {
2247 tracing::debug!("Attempting direct address import for: {}", address);
2248
2249 // This is a fallback method - we'll use empty string for wallet name to use default behavior
2250 // Note: This may not work as expected with the new signature, but kept for compatibility
2251 Err(AmpError::rpc(
2252 "Direct address import not supported with wallet-specific import_address".to_string(),
2253 ))
2254 }
2255
2256 /// Broadcasts a signed raw transaction to the network
2257 ///
2258 /// # Arguments
2259 /// * `hex` - The signed transaction in hexadecimal format
2260 ///
2261 /// # Errors
2262 /// Returns an error if the RPC call fails or transaction broadcast fails
2263 ///
2264 /// # Examples
2265 /// ```no_run
2266 /// # use amp_rs::ElementsRpc;
2267 /// # #[tokio::main]
2268 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2269 /// let rpc = ElementsRpc::from_env()?;
2270 /// let signed_tx_hex = "0200000000..."; // Signed transaction hex
2271 /// let txid = rpc.send_raw_transaction(signed_tx_hex).await?;
2272 /// println!("Transaction broadcast with ID: {}", txid);
2273 /// # Ok(())
2274 /// # }
2275 /// ```
2276 pub async fn send_raw_transaction(&self, hex: &str) -> Result<String, AmpError> {
2277 tracing::debug!(
2278 "Broadcasting raw transaction: {}",
2279 &hex[..std::cmp::min(hex.len(), 64)]
2280 );
2281
2282 let params = serde_json::json!([hex]);
2283
2284 let txid: String = self
2285 .rpc_call("sendrawtransaction", params)
2286 .await
2287 .map_err(|e| {
2288 tracing::error!("Raw transaction broadcast failed: {}", e);
2289 tracing::error!("Transaction hex (first 200 chars): {}", &hex[..std::cmp::min(hex.len(), 200)]);
2290
2291 // Provide specific guidance for blinding-related errors
2292 if e.to_string().contains("bad-txns-in-ne-out") || e.to_string().contains("value in != value out") {
2293 AmpError::rpc(format!(
2294 "Transaction broadcast failed due to confidential transaction blinding error. \
2295 This indicates that the blinding factors don't balance properly. \
2296 Possible solutions:\n\
2297 1. Ensure all addresses have proper blinding keys in the wallet\n\
2298 2. Verify that blindrawtransaction was called before signing\n\
2299 3. Check that UTXO blinding factors match between Elements and LWK\n\
2300 4. Try using unconfidential addresses for testing\n\
2301 Original error: {e}"
2302 ))
2303 } else {
2304 e.with_context("Failed to broadcast raw transaction")
2305 }
2306 })?;
2307
2308 tracing::info!("Successfully broadcast transaction with ID: {}", txid);
2309 Ok(txid)
2310 }
2311
2312 /// Retrieves detailed information about a transaction
2313 ///
2314 /// # Arguments
2315 /// * `txid` - The transaction ID to retrieve
2316 ///
2317 /// # Errors
2318 /// Returns an error if the RPC call fails or transaction is not found
2319 ///
2320 /// # Examples
2321 /// ```no_run
2322 /// # use amp_rs::ElementsRpc;
2323 /// # #[tokio::main]
2324 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2325 /// let rpc = ElementsRpc::from_env()?;
2326 /// let tx_detail = rpc.get_transaction("abc123...").await?;
2327 /// println!("Transaction has {} confirmations", tx_detail.confirmations);
2328 /// # Ok(())
2329 /// # }
2330 /// ```
2331 pub async fn get_transaction(&self, txid: &str) -> Result<TransactionDetail, AmpError> {
2332 tracing::debug!("Retrieving transaction details for: {}", txid);
2333
2334 let params = serde_json::json!([txid, true]); // true for verbose output
2335
2336 let tx_detail: TransactionDetail = self
2337 .rpc_call("gettransaction", params)
2338 .await
2339 .map_err(|e| e.with_context(format!("Failed to get transaction details for {txid}")))?;
2340
2341 tracing::debug!(
2342 "Retrieved transaction {} with {} confirmations",
2343 txid,
2344 tx_detail.confirmations
2345 );
2346
2347 Ok(tx_detail)
2348 }
2349
2350 /// Sends multiple outputs to multiple addresses using Elements' sendmany RPC
2351 ///
2352 /// This method uses Elements' built-in sendmany command which properly handles
2353 /// confidential transactions and blinding. This is the recommended approach for
2354 /// asset distribution as it avoids manual transaction construction issues.
2355 ///
2356 /// # Arguments
2357 /// * `wallet_name` - Name of the Elements wallet to use
2358 /// * `address_amounts` - Map of addresses to amounts to send
2359 /// * `asset_amounts` - Map of addresses to asset IDs for each output
2360 /// * `min_conf` - Minimum confirmations for inputs (default: 1)
2361 /// * `comment` - Optional transaction comment
2362 /// * `subtract_fee_from` - Optional addresses to subtract fees from
2363 /// * `replaceable` - Whether transaction is replaceable (default: false)
2364 /// * `conf_target` - Confirmation target for fee estimation (default: 1)
2365 /// * `estimate_mode` - Fee estimation mode (default: "UNSET")
2366 ///
2367 /// # Returns
2368 /// Returns the transaction ID of the sent transaction
2369 ///
2370 /// # Errors
2371 /// Returns an error if the RPC call fails or transaction creation fails
2372 ///
2373 /// # Examples
2374 /// ```no_run
2375 /// # use amp_rs::ElementsRpc;
2376 /// # use std::collections::HashMap;
2377 /// # #[tokio::main]
2378 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2379 /// let rpc = ElementsRpc::from_env()?;
2380 ///
2381 /// let mut address_amounts = HashMap::new();
2382 /// address_amounts.insert("address1".to_string(), 100.0);
2383 /// address_amounts.insert("address2".to_string(), 50.0);
2384 ///
2385 /// let mut asset_amounts = HashMap::new();
2386 /// asset_amounts.insert("address1".to_string(), "asset_id_hex".to_string());
2387 /// asset_amounts.insert("address2".to_string(), "asset_id_hex".to_string());
2388 ///
2389 /// let txid = rpc.sendmany("wallet_name", address_amounts, asset_amounts, None, None, None, None, None, None).await?;
2390 /// println!("Transaction sent with ID: {}", txid);
2391 /// # Ok(())
2392 /// # }
2393 /// ```
2394 #[allow(clippy::too_many_arguments, clippy::cognitive_complexity)]
2395 pub async fn sendmany(
2396 &self,
2397 wallet_name: &str,
2398 address_amounts: std::collections::HashMap<String, f64>,
2399 asset_amounts: std::collections::HashMap<String, String>,
2400 min_conf: Option<u32>,
2401 comment: Option<&str>,
2402 subtract_fee_from: Option<Vec<String>>,
2403 replaceable: Option<bool>,
2404 conf_target: Option<u32>,
2405 estimate_mode: Option<&str>,
2406 ) -> Result<String, AmpError> {
2407 tracing::debug!(
2408 "Sending to {} addresses using sendmany for wallet {}",
2409 address_amounts.len(),
2410 wallet_name
2411 );
2412
2413 // First load the wallet to ensure it's available
2414 self.load_wallet(wallet_name).await?;
2415
2416 // Elements sendmany parameters:
2417 // 1. dummy (empty string for compatibility)
2418 // 2. amounts (map of address -> amount)
2419 // 3. minconf (minimum confirmations, default 1)
2420 // 4. comment (optional comment)
2421 // 5. subtractfeefrom (array of addresses to subtract fee from)
2422 // 6. replaceable (boolean, default false)
2423 // 7. conf_target (confirmation target for fee estimation)
2424 // 8. estimate_mode (fee estimation mode)
2425 // 9. assetlabel (map of address -> asset_id for multi-asset sends)
2426 let params = serde_json::json!([
2427 "", // dummy (required for compatibility)
2428 address_amounts, // amounts map
2429 min_conf.unwrap_or(1), // minconf
2430 comment.unwrap_or(""), // comment
2431 subtract_fee_from.unwrap_or_default(), // subtractfeefrom
2432 replaceable.unwrap_or(false), // replaceable
2433 conf_target.unwrap_or(1), // conf_target
2434 estimate_mode.unwrap_or("UNSET"), // estimate_mode
2435 asset_amounts // assetlabel (asset map)
2436 ]);
2437
2438 // Use the wallet-specific RPC endpoint
2439 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
2440
2441 let request = RpcRequest {
2442 jsonrpc: "1.0".to_string(),
2443 id: "amp-client".to_string(),
2444 method: "sendmany".to_string(),
2445 params,
2446 };
2447
2448 tracing::debug!("Sendmany request parameters:");
2449 tracing::debug!(" wallet: {}", wallet_name);
2450 tracing::debug!(" address_amounts: {:?}", address_amounts);
2451 tracing::debug!(" asset_amounts: {:?}", asset_amounts);
2452
2453 let response = self
2454 .client
2455 .post(&wallet_url)
2456 .basic_auth(&self.username, Some(&self.password))
2457 .json(&request)
2458 .send()
2459 .await
2460 .map_err(|e| AmpError::rpc(format!("Failed to send sendmany RPC request: {e}")))?;
2461
2462 if !response.status().is_success() {
2463 let status = response.status();
2464 let error_body = response
2465 .text()
2466 .await
2467 .unwrap_or_else(|_| "Unable to read error body".to_string());
2468 return Err(AmpError::rpc(format!(
2469 "Sendmany RPC request failed with status: {status} - Body: {error_body}"
2470 )));
2471 }
2472
2473 let rpc_response: RpcResponse<String> = response
2474 .json()
2475 .await
2476 .map_err(|e| AmpError::rpc(format!("Failed to parse sendmany RPC response: {e}")))?;
2477
2478 if let Some(error) = rpc_response.error {
2479 return Err(AmpError::rpc(format!(
2480 "Sendmany RPC error: {} (code: {})",
2481 error.message, error.code
2482 )));
2483 }
2484
2485 let txid = rpc_response.result.unwrap_or_default();
2486 tracing::info!("Successfully sent transaction with sendmany: {}", txid);
2487 Ok(txid)
2488 }
2489
2490 /// Waits for blockchain confirmations with configurable timeout
2491 ///
2492 /// This method polls the blockchain every 15 seconds to check for transaction confirmations.
2493 /// It waits for a minimum number of confirmations (default 2) before returning successfully.
2494 /// The method includes a configurable timeout to prevent indefinite waiting.
2495 ///
2496 /// # Arguments
2497 /// * `txid` - The transaction ID to monitor for confirmations
2498 /// * `min_confirmations` - Minimum number of confirmations required (default: 2)
2499 /// * `timeout_minutes` - Timeout in minutes (default: 10)
2500 ///
2501 /// # Returns
2502 /// Returns the final `TransactionDetail` when sufficient confirmations are reached
2503 ///
2504 /// # Errors
2505 /// Returns `AmpError::Timeout` if the timeout is exceeded before confirmations are received
2506 /// Returns `AmpError::Rpc` if there are issues communicating with the Elements node
2507 ///
2508 /// # Examples
2509 /// ```no_run
2510 /// # use amp_rs::ElementsRpc;
2511 /// # #[tokio::main]
2512 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2513 /// let rpc = ElementsRpc::from_env()?;
2514 /// let tx_detail = rpc.wait_for_confirmations("abc123...", Some(2), Some(10)).await?;
2515 /// println!("Transaction confirmed with {} confirmations", tx_detail.confirmations);
2516 /// # Ok(())
2517 /// # }
2518 /// ```
2519 pub async fn wait_for_confirmations(
2520 &self,
2521 txid: &str,
2522 min_confirmations: Option<u32>,
2523 timeout_minutes: Option<u64>,
2524 ) -> Result<TransactionDetail, AmpError> {
2525 self.wait_for_confirmations_with_interval(txid, min_confirmations, timeout_minutes, None)
2526 .await
2527 }
2528
2529 /// Internal method for waiting for confirmations with configurable poll interval
2530 /// This is primarily used for testing to avoid long waits
2531 ///
2532 /// # Errors
2533 ///
2534 /// Returns an error if:
2535 /// - The timeout is exceeded before confirmations are received
2536 /// - There are issues communicating with the Elements node
2537 /// - The transaction cannot be found or is invalid
2538 #[allow(clippy::cognitive_complexity)]
2539 pub async fn wait_for_confirmations_with_interval(
2540 &self,
2541 txid: &str,
2542 min_confirmations: Option<u32>,
2543 timeout_minutes: Option<u64>,
2544 poll_interval_secs: Option<u64>,
2545 ) -> Result<TransactionDetail, AmpError> {
2546 let min_confirmations = min_confirmations.unwrap_or(2);
2547 let timeout_minutes = timeout_minutes.unwrap_or(10);
2548 let timeout_duration = if timeout_minutes == 0 {
2549 std::time::Duration::from_secs(3) // Minimum 3 seconds for testing
2550 } else {
2551 std::time::Duration::from_secs(timeout_minutes * 60)
2552 };
2553 let poll_interval = std::time::Duration::from_secs(poll_interval_secs.unwrap_or(15));
2554
2555 tracing::info!(
2556 "Starting confirmation monitoring for transaction {} (min_confirmations: {}, timeout: {} minutes)",
2557 txid,
2558 min_confirmations,
2559 timeout_minutes
2560 );
2561
2562 let start_time = std::time::Instant::now();
2563
2564 loop {
2565 // Check if we've exceeded the timeout
2566 if start_time.elapsed() >= timeout_duration {
2567 let error_msg = format!(
2568 "Timeout waiting for confirmations after {timeout_minutes} minutes. Transaction ID: {txid}. \
2569 You can retry confirmation by calling the confirmation API with this txid."
2570 );
2571 tracing::error!("{}", error_msg);
2572 return Err(AmpError::Timeout(error_msg));
2573 }
2574
2575 // Get current transaction details
2576 match self.get_transaction(txid).await {
2577 Ok(tx_detail) => {
2578 tracing::debug!(
2579 "Transaction {} has {} confirmations (need {})",
2580 txid,
2581 tx_detail.confirmations,
2582 min_confirmations
2583 );
2584
2585 if tx_detail.confirmations >= min_confirmations {
2586 tracing::info!(
2587 "Transaction {} confirmed with {} confirmations",
2588 txid,
2589 tx_detail.confirmations
2590 );
2591 return Ok(tx_detail);
2592 }
2593
2594 // Log progress every few polls to avoid spam
2595 if start_time.elapsed().as_secs() % 60 < 15 {
2596 tracing::info!(
2597 "Waiting for confirmations: {}/{} (elapsed: {}s)",
2598 tx_detail.confirmations,
2599 min_confirmations,
2600 start_time.elapsed().as_secs()
2601 );
2602 }
2603 }
2604 Err(e) => {
2605 tracing::warn!(
2606 "Failed to get transaction details for {}: {}. Retrying in {} seconds...",
2607 txid,
2608 e,
2609 poll_interval.as_secs()
2610 );
2611 // Continue polling even if individual calls fail, as the transaction
2612 // might not be visible immediately after broadcasting
2613 }
2614 }
2615
2616 // Wait before next poll
2617 tokio::time::sleep(poll_interval).await;
2618 }
2619 }
2620
2621 /// Selects appropriate UTXOs to cover the required amount plus fees
2622 ///
2623 /// This method implements a simple UTXO selection algorithm that:
2624 /// 1. Filters UTXOs by asset ID and spendability
2625 /// 2. Sorts UTXOs by amount (largest first) for efficiency
2626 /// 3. Selects UTXOs until the target amount plus estimated fees is covered
2627 ///
2628 /// # Arguments
2629 /// * `asset_id` - The asset ID to select UTXOs for
2630 /// * `target_amount` - The total amount needed for distribution
2631 /// * `estimated_fee` - Estimated transaction fee in the same asset
2632 ///
2633 /// # Returns
2634 /// Returns a tuple of (`selected_utxos`, `total_selected_amount`)
2635 ///
2636 /// # Errors
2637 /// Returns an error if insufficient UTXOs are available or RPC calls fail
2638 ///
2639 /// # Examples
2640 /// ```no_run
2641 /// # use amp_rs::ElementsRpc;
2642 /// # #[tokio::main]
2643 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2644 /// let rpc = ElementsRpc::from_env()?;
2645 /// let (selected_utxos, total_amount) = rpc.select_utxos_for_amount(
2646 /// "wallet_name",
2647 /// "asset_id_hex",
2648 /// 150.0,
2649 /// 0.001
2650 /// ).await?;
2651 /// println!("Selected {} UTXOs totaling {}", selected_utxos.len(), total_amount);
2652 /// # Ok(())
2653 /// # }
2654 /// ```
2655 pub async fn select_utxos_for_amount(
2656 &self,
2657 wallet_name: &str,
2658 asset_id: &str,
2659 target_amount: f64,
2660 estimated_fee: f64,
2661 ) -> Result<(Vec<Unspent>, f64), AmpError> {
2662 tracing::debug!(
2663 "Selecting UTXOs for asset {} from wallet {} - target: {}, fee: {}",
2664 asset_id,
2665 wallet_name,
2666 target_amount,
2667 estimated_fee
2668 );
2669
2670 // Get all UTXOs for this asset from the specified wallet
2671 let mut utxos = self
2672 .list_unspent_for_wallet(wallet_name, Some(asset_id))
2673 .await?;
2674
2675 // Filter for spendable UTXOs only
2676 utxos.retain(|utxo| utxo.spendable && utxo.asset == asset_id);
2677
2678 if utxos.is_empty() {
2679 return Err(AmpError::validation(format!(
2680 "No spendable UTXOs found for asset {asset_id}. \
2681 This typically means:\n\
2682 1. The treasury address is not imported in the Elements node as a watch-only address\n\
2683 2. The asset issuance transaction hasn't been confirmed yet\n\
2684 3. The UTXOs have already been spent\n\
2685 \n\
2686 To fix this:\n\
2687 - Ensure the treasury address is imported: `elements-cli importaddress <treasury_address> treasury false`\n\
2688 - Wait for the asset issuance transaction to be confirmed\n\
2689 - Check that the treasury address matches the one used for asset issuance"
2690 )));
2691 }
2692
2693 // Sort UTXOs by amount (largest first) for efficient selection
2694 utxos.sort_by(|a, b| {
2695 b.amount
2696 .partial_cmp(&a.amount)
2697 .unwrap_or(std::cmp::Ordering::Equal)
2698 });
2699
2700 let required_amount = target_amount + estimated_fee;
2701 let mut selected_utxos = Vec::new();
2702 let mut total_selected = 0.0;
2703
2704 // Select UTXOs until we have enough to cover the required amount
2705 for utxo in utxos {
2706 selected_utxos.push(utxo.clone());
2707 total_selected += utxo.amount;
2708
2709 if total_selected >= required_amount {
2710 break;
2711 }
2712 }
2713
2714 // Check if we have sufficient funds
2715 if total_selected < required_amount {
2716 return Err(AmpError::validation(format!(
2717 "Insufficient UTXOs: need {required_amount}, have {total_selected} (target: {target_amount}, fee: {estimated_fee})"
2718 )));
2719 }
2720
2721 tracing::info!(
2722 "Selected {} UTXOs totaling {} for target {} + fee {}",
2723 selected_utxos.len(),
2724 total_selected,
2725 target_amount,
2726 estimated_fee
2727 );
2728
2729 Ok((selected_utxos, total_selected))
2730 }
2731
2732 /// Builds a raw transaction for asset distribution with proper change handling
2733 ///
2734 /// This method orchestrates the complete transaction building process:
2735 /// 1. Selects appropriate UTXOs using `select_utxos_for_amount`
2736 /// 2. Creates transaction inputs from selected UTXOs
2737 /// 3. Creates outputs for distribution addresses
2738 /// 4. Calculates and creates change output if necessary
2739 /// 5. Builds the raw transaction using `create_raw_transaction`
2740 ///
2741 /// # Arguments
2742 /// * `asset_id` - The asset ID being distributed
2743 /// * `address_amounts` - Map of recipient addresses to amounts
2744 /// * `change_address` - Address to send change to (if any)
2745 /// * `estimated_fee` - Estimated transaction fee
2746 ///
2747 /// # Returns
2748 /// Returns a tuple of (`raw_transaction_hex`, `selected_utxos`, `change_amount`)
2749 ///
2750 /// # Errors
2751 /// Returns an error if UTXO selection fails or transaction building fails
2752 ///
2753 /// # Examples
2754 /// ```no_run
2755 /// # use amp_rs::ElementsRpc;
2756 /// # use std::collections::HashMap;
2757 /// # #[tokio::main]
2758 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2759 /// let rpc = ElementsRpc::from_env()?;
2760 /// let mut address_amounts = HashMap::new();
2761 /// address_amounts.insert("address1".to_string(), 100.0);
2762 /// address_amounts.insert("address2".to_string(), 50.0);
2763 ///
2764 /// let (raw_tx, utxos, change) = rpc.build_distribution_transaction(
2765 /// "wallet_name",
2766 /// "asset_id_hex",
2767 /// address_amounts,
2768 /// "change_address",
2769 /// 0.001
2770 /// ).await?;
2771 /// println!("Built transaction with {} inputs, change: {}", utxos.len(), change);
2772 /// # Ok(())
2773 /// # }
2774 /// ```
2775 #[allow(clippy::cognitive_complexity)]
2776 #[allow(clippy::too_many_lines)]
2777 pub async fn build_distribution_transaction(
2778 &self,
2779 wallet_name: &str,
2780 asset_id: &str,
2781 address_amounts: std::collections::HashMap<String, f64>,
2782 change_address: &str,
2783 _estimated_fee: f64,
2784 ) -> Result<(String, Vec<Unspent>, f64), AmpError> {
2785 const DUST_THRESHOLD: f64 = 0.00001;
2786 const LBTC_ASSET_ID: &str =
2787 "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; // L-BTC on Liquid testnet
2788
2789 tracing::debug!(
2790 "Building distribution transaction for asset {} with {} outputs",
2791 asset_id,
2792 address_amounts.len()
2793 );
2794
2795 // Calculate total distribution amount
2796 let total_distribution: f64 = address_amounts.values().sum();
2797
2798 if total_distribution <= 0.0 {
2799 return Err(AmpError::validation(
2800 "Total distribution amount must be greater than zero".to_string(),
2801 ));
2802 }
2803
2804 // Select UTXOs to cover the distribution (custom asset)
2805 let (selected_asset_utxos, total_selected) = self
2806 .select_utxos_for_amount(wallet_name, asset_id, total_distribution, 0.0)
2807 .await?;
2808
2809 // Also select L-BTC UTXOs for transaction fees
2810 // Elements requires L-BTC inputs for fees even when distributing custom assets
2811 let min_lbtc_fee = 0.00001; // Minimum L-BTC needed for fees
2812 let (selected_lbtc_utxos, lbtc_total) = match self
2813 .select_utxos_for_amount(wallet_name, LBTC_ASSET_ID, 0.0, min_lbtc_fee)
2814 .await
2815 {
2816 Ok((utxos, total)) => {
2817 tracing::info!(
2818 "Selected {} L-BTC UTXOs totaling {} for fees",
2819 utxos.len(),
2820 total
2821 );
2822 (utxos, total)
2823 }
2824 Err(e) => {
2825 tracing::warn!(
2826 "Could not select L-BTC UTXOs for fees: {}. Transaction may fail.",
2827 e
2828 );
2829 (Vec::new(), 0.0)
2830 }
2831 };
2832
2833 // Combine custom asset UTXOs and L-BTC UTXOs
2834 let mut all_utxos = selected_asset_utxos.clone();
2835 all_utxos.extend(selected_lbtc_utxos.clone());
2836
2837 if selected_lbtc_utxos.is_empty() {
2838 tracing::warn!(
2839 "No L-BTC UTXOs selected for fees. Transaction may fail during broadcast."
2840 );
2841 } else {
2842 tracing::info!(
2843 "Transaction includes {} custom asset UTXOs and {} L-BTC UTXOs for fees",
2844 selected_asset_utxos.len(),
2845 selected_lbtc_utxos.len()
2846 );
2847 }
2848
2849 // Create transaction inputs from all selected UTXOs
2850 let inputs: Vec<TxInput> = all_utxos
2851 .iter()
2852 .map(|utxo| TxInput {
2853 txid: utxo.txid.clone(),
2854 vout: utxo.vout,
2855 sequence: None, // Use default sequence
2856 })
2857 .collect();
2858
2859 // Create outputs for distribution (custom asset)
2860 // We need to track outputs as a vector since we may have multiple outputs to the same address
2861 // (e.g., custom asset change + L-BTC change to the same change address)
2862 let mut output_list = Vec::new();
2863
2864 // Add distribution outputs (custom asset)
2865 for (address, amount) in &address_amounts {
2866 output_list.push((address.clone(), *amount, asset_id.to_string()));
2867 }
2868
2869 // Calculate change amount for custom asset (total selected - distribution)
2870 let asset_change_amount = total_selected - total_distribution;
2871
2872 // Add asset change output if there's a significant amount left
2873 if asset_change_amount > DUST_THRESHOLD {
2874 output_list.push((
2875 change_address.to_string(),
2876 asset_change_amount,
2877 asset_id.to_string(),
2878 ));
2879
2880 tracing::debug!(
2881 "Adding asset change output: {} {} to address {}",
2882 asset_change_amount,
2883 asset_id,
2884 change_address
2885 );
2886 } else if asset_change_amount > 0.0 {
2887 tracing::warn!(
2888 "Asset change amount {} is below dust threshold {}, will be lost",
2889 asset_change_amount,
2890 DUST_THRESHOLD
2891 );
2892 }
2893
2894 // Handle L-BTC change if we selected L-BTC UTXOs for fees
2895 // In Elements, the fee is implicit - it's the difference between L-BTC inputs and outputs
2896 // We should NOT subtract the fee from outputs; Elements calculates it automatically
2897 if !selected_lbtc_utxos.is_empty() {
2898 tracing::debug!(
2899 "L-BTC input total: {}, minimum fee needed: {}",
2900 lbtc_total,
2901 min_lbtc_fee
2902 );
2903
2904 // Check if we have enough L-BTC for the minimum fee
2905 if lbtc_total < min_lbtc_fee {
2906 return Err(AmpError::validation(format!(
2907 "Insufficient L-BTC for fees: have {lbtc_total}, need at least {min_lbtc_fee}"
2908 )));
2909 }
2910
2911 // For now, let's try NOT adding any L-BTC change output
2912 // and let Elements handle the fee automatically from the input/output difference
2913 tracing::info!(
2914 "Using L-BTC input {} for fees - no explicit L-BTC change output (Elements will handle fee automatically)",
2915 lbtc_total
2916 );
2917
2918 // Note: If this approach works, the entire L-BTC input will become the fee
2919 // If we need change, we'll need to figure out the correct way to handle it
2920 }
2921
2922 // For confidential addresses, we need to import them into the wallet first
2923 // so Elements knows about the blinding keys
2924 for address in address_amounts.keys() {
2925 if address.starts_with('v') {
2926 // Confidential address
2927 tracing::debug!("Importing confidential address into wallet: {}", address);
2928 if let Err(e) = self
2929 .import_address_to_wallet(wallet_name, address, None, false)
2930 .await
2931 {
2932 tracing::warn!("Failed to import confidential address {}: {}", address, e);
2933 // Continue anyway - the address might already be imported
2934 }
2935 }
2936 }
2937
2938 // Build the raw transaction using wallet-specific endpoint for confidential transactions
2939 // For confidential transactions, we need to use blindrawtransaction to properly handle blinding
2940 let raw_transaction = self
2941 .create_raw_transaction_with_outputs(wallet_name, inputs, output_list)
2942 .await
2943 .map_err(|e| {
2944 // Provide more helpful error message for the common L-BTC fee issue
2945 if e.to_string().contains("bad-txns-in-ne-out") || e.to_string().contains("value in != value out") {
2946 AmpError::validation(format!(
2947 "Transaction failed due to confidential transaction blinding mismatch. \
2948 This occurs when Elements creates blinding factors that don't match LWK's expectations. \
2949 To fix this:\n\
2950 1. Ensure the wallet has proper blinding keys for all addresses\n\
2951 2. Use blindrawtransaction before signing\n\
2952 3. Verify UTXO blinding factors match between Elements and LWK\n\
2953 4. Original error: {e}"
2954 ))
2955 } else {
2956 e.with_context("Failed to build distribution transaction")
2957 }
2958 })?;
2959
2960 // For confidential transactions, we need to blind the transaction properly
2961 // This ensures the blinding factors are compatible with LWK signing
2962 tracing::debug!("Blinding raw transaction for confidential asset distribution");
2963 let blinded_transaction = self
2964 .blind_raw_transaction(wallet_name, &raw_transaction)
2965 .await
2966 .map_err(|e| {
2967 tracing::warn!(
2968 "Failed to blind transaction, proceeding with unblinded: {}",
2969 e
2970 );
2971 // If blinding fails, we'll try to proceed with the unblinded transaction
2972 // This might work for some cases but could fail during broadcast
2973 e.with_context("Transaction blinding failed")
2974 })
2975 .unwrap_or_else(|_| {
2976 tracing::warn!("Using unblinded transaction - this may cause broadcast failures");
2977 raw_transaction.clone()
2978 });
2979
2980 tracing::info!(
2981 "Built distribution transaction: {} inputs, {} outputs, asset change: {}",
2982 all_utxos.len(),
2983 address_amounts.len() + usize::from(asset_change_amount > DUST_THRESHOLD),
2984 if asset_change_amount > DUST_THRESHOLD {
2985 asset_change_amount
2986 } else {
2987 0.0
2988 }
2989 );
2990
2991 Ok((blinded_transaction, all_utxos, asset_change_amount))
2992 }
2993
2994 /// Creates a raw transaction with multiple outputs that can handle multiple assets to the same address
2995 ///
2996 /// This method is similar to `create_raw_transaction_with_wallet` but handles the case where
2997 /// multiple outputs with different assets need to go to the same address (e.g., asset change + L-BTC change).
2998 ///
2999 /// # Arguments
3000 /// * `wallet_name` - Name of the Elements wallet to use
3001 /// * `inputs` - Vector of transaction inputs
3002 /// * `outputs` - Vector of (address, amount, `asset_id`) tuples
3003 ///
3004 /// # Returns
3005 /// Returns the raw transaction hex string
3006 #[allow(clippy::cognitive_complexity)]
3007 async fn create_raw_transaction_with_outputs(
3008 &self,
3009 wallet_name: &str,
3010 inputs: Vec<TxInput>,
3011 outputs: Vec<(String, f64, String)>, // (address, amount, asset_id)
3012 ) -> Result<String, AmpError> {
3013 tracing::debug!(
3014 "Creating raw transaction with wallet {} - {} inputs and {} outputs",
3015 wallet_name,
3016 inputs.len(),
3017 outputs.len()
3018 );
3019
3020 // First load the wallet to ensure it's available
3021 self.load_wallet(wallet_name).await?;
3022
3023 // Elements RPC createrawtransaction expects outputs as an array of objects
3024 // Each output object should contain both address, amount, and asset
3025 let mut outputs_array = Vec::new();
3026
3027 for (address, amount, asset_id) in &outputs {
3028 // Convert amount to string with proper precision for Elements
3029 let amount_str = format!("{amount:.8}");
3030
3031 outputs_array.push(serde_json::json!({
3032 address.clone(): amount_str,
3033 "asset": asset_id
3034 }));
3035 }
3036
3037 let params = serde_json::json!([
3038 inputs, // inputs as TxInput array
3039 outputs_array, // outputs as array of {address: amount, asset: id} objects
3040 0, // locktime (0 = no locktime)
3041 false, // replaceable (false = not replaceable)
3042 ]);
3043
3044 // Debug: Log the exact parameters being sent to createrawtransaction
3045 tracing::error!("createrawtransaction parameters (wallet-specific, corrected format):");
3046 tracing::error!(" wallet: {}", wallet_name);
3047 tracing::error!(
3048 " inputs: {}",
3049 serde_json::to_string_pretty(&inputs).unwrap_or_default()
3050 );
3051 tracing::error!(
3052 " outputs_array: {}",
3053 serde_json::to_string_pretty(&outputs_array).unwrap_or_default()
3054 );
3055
3056 // Use the wallet-specific RPC endpoint
3057 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3058
3059 let request = RpcRequest {
3060 jsonrpc: "1.0".to_string(),
3061 id: "amp-client".to_string(),
3062 method: "createrawtransaction".to_string(),
3063 params,
3064 };
3065
3066 let response = self
3067 .client
3068 .post(&wallet_url)
3069 .basic_auth(&self.username, Some(&self.password))
3070 .json(&request)
3071 .send()
3072 .await
3073 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3074
3075 if !response.status().is_success() {
3076 let status = response.status();
3077 let error_body = response
3078 .text()
3079 .await
3080 .unwrap_or_else(|_| "Unable to read error body".to_string());
3081 return Err(AmpError::rpc(format!(
3082 "RPC request failed with status: {status} - Body: {error_body}"
3083 )));
3084 }
3085
3086 let rpc_response: RpcResponse<String> = response
3087 .json()
3088 .await
3089 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
3090
3091 if let Some(error) = rpc_response.error {
3092 return Err(AmpError::rpc(format!(
3093 "RPC error creating raw transaction: {} (code: {})",
3094 error.message, error.code
3095 )));
3096 }
3097
3098 Ok(rpc_response.result.unwrap_or_default())
3099 }
3100
3101 /// Blinds a raw transaction for confidential transactions
3102 ///
3103 /// This method uses Elements' blindrawtransaction RPC to properly blind a transaction
3104 /// for confidential asset transfers. This is crucial for Liquid transactions to ensure
3105 /// the blinding factors are properly balanced.
3106 ///
3107 /// # Arguments
3108 /// * `wallet_name` - Name of the Elements wallet to use for blinding
3109 /// * `raw_transaction` - The raw transaction hex to blind
3110 ///
3111 /// # Returns
3112 /// Returns the blinded transaction hex string
3113 ///
3114 /// # Errors
3115 /// Returns an error if the RPC call fails or blinding is not possible
3116 pub async fn blind_raw_transaction(
3117 &self,
3118 wallet_name: &str,
3119 raw_transaction: &str,
3120 ) -> Result<String, AmpError> {
3121 tracing::debug!(
3122 "Blinding raw transaction for wallet {} - tx length: {} chars",
3123 wallet_name,
3124 raw_transaction.len()
3125 );
3126
3127 // First load the wallet to ensure it's available
3128 self.load_wallet(wallet_name).await?;
3129
3130 // Elements blindrawtransaction parameters:
3131 // 1. Raw transaction hex
3132 // 2. Input blinding data (can be empty array for auto-detection)
3133 // 3. Input amounts (can be empty array for auto-detection from UTXOs)
3134 // 4. Input assets (can be empty array for auto-detection from UTXOs)
3135 // 5. Input asset blinders (can be empty array for auto-detection)
3136 // 6. Input amount blinders (can be empty array for auto-detection)
3137 let params = serde_json::json!([
3138 raw_transaction, // Raw transaction hex
3139 [], // Input blinding data (empty for auto-detection)
3140 [], // Input amounts (empty for auto-detection)
3141 [], // Input assets (empty for auto-detection)
3142 [], // Input asset blinders (empty for auto-detection)
3143 [] // Input amount blinders (empty for auto-detection)
3144 ]);
3145
3146 // Use the wallet-specific RPC endpoint
3147 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3148
3149 let request = RpcRequest {
3150 jsonrpc: "1.0".to_string(),
3151 id: "amp-client".to_string(),
3152 method: "blindrawtransaction".to_string(),
3153 params,
3154 };
3155
3156 let response = self
3157 .client
3158 .post(&wallet_url)
3159 .basic_auth(&self.username, Some(&self.password))
3160 .json(&request)
3161 .send()
3162 .await
3163 .map_err(|e| {
3164 AmpError::rpc(format!("Failed to send blindrawtransaction request: {e}"))
3165 })?;
3166
3167 if !response.status().is_success() {
3168 let status = response.status();
3169 let error_body = response
3170 .text()
3171 .await
3172 .unwrap_or_else(|_| "Unable to read error body".to_string());
3173 return Err(AmpError::rpc(format!(
3174 "blindrawtransaction failed with status: {status} - Body: {error_body}"
3175 )));
3176 }
3177
3178 let rpc_response: RpcResponse<String> = response.json().await.map_err(|e| {
3179 AmpError::rpc(format!("Failed to parse blindrawtransaction response: {e}"))
3180 })?;
3181
3182 if let Some(error) = rpc_response.error {
3183 return Err(AmpError::rpc(format!(
3184 "RPC error blinding transaction: {} (code: {})",
3185 error.message, error.code
3186 )));
3187 }
3188
3189 let blinded_tx = rpc_response.result.unwrap_or_default();
3190
3191 tracing::info!(
3192 "Successfully blinded transaction - original: {} chars, blinded: {} chars",
3193 raw_transaction.len(),
3194 blinded_tx.len()
3195 );
3196
3197 Ok(blinded_tx)
3198 }
3199
3200 /// Signs a raw transaction using the provided signer callback
3201 ///
3202 /// This method integrates with the Signer trait to sign unsigned transactions.
3203 /// It handles the complete signing workflow including:
3204 /// 1. Validation of the unsigned transaction hex format
3205 /// 2. Calling the signer's `sign_transaction` method
3206 /// 3. Validation of the signed transaction format and structure
3207 /// 4. Proper error handling and context propagation
3208 ///
3209 /// # Arguments
3210 /// * `unsigned_tx_hex` - The unsigned transaction in hexadecimal format
3211 /// * `signer` - Implementation of the Signer trait for transaction signing
3212 ///
3213 /// # Returns
3214 /// Returns the signed transaction as a hex string
3215 ///
3216 /// # Errors
3217 /// Returns an error if:
3218 /// - The unsigned transaction hex is invalid or malformed
3219 /// - The signer fails to sign the transaction
3220 /// - The signed transaction format is invalid
3221 /// - Any validation checks fail
3222 ///
3223 /// # Examples
3224 /// ```no_run
3225 /// # use amp_rs::{ElementsRpc, signer::{Signer, LwkSoftwareSigner}};
3226 /// # #[tokio::main]
3227 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3228 /// let rpc = ElementsRpc::from_env()?;
3229 /// let (_, signer) = LwkSoftwareSigner::generate_new()?;
3230 /// let unsigned_tx = "020000000001..."; // Unsigned transaction hex
3231 /// let signed_tx = rpc.sign_transaction(unsigned_tx, &signer).await?;
3232 /// println!("Transaction signed successfully: {}", signed_tx);
3233 /// # Ok(())
3234 /// # }
3235 /// ```
3236 #[allow(clippy::cognitive_complexity)]
3237 pub async fn sign_transaction(
3238 &self,
3239 unsigned_tx_hex: &str,
3240 signer: &dyn crate::signer::Signer,
3241 ) -> Result<String, AmpError> {
3242 const MIN_TX_SIZE: usize = 10; // Minimum bytes for a valid transaction
3243
3244 tracing::debug!(
3245 "Signing transaction: {}...",
3246 &unsigned_tx_hex[..std::cmp::min(unsigned_tx_hex.len(), 64)]
3247 );
3248
3249 // Validate unsigned transaction hex format
3250 if unsigned_tx_hex.is_empty() {
3251 return Err(AmpError::validation(
3252 "Unsigned transaction hex cannot be empty".to_string(),
3253 ));
3254 }
3255
3256 // Check if hex string has valid format (even length, valid hex characters)
3257 if unsigned_tx_hex.len() % 2 != 0 {
3258 return Err(AmpError::validation(
3259 "Unsigned transaction hex must have even length".to_string(),
3260 ));
3261 }
3262
3263 // Validate hex characters
3264 if !unsigned_tx_hex.chars().all(|c| c.is_ascii_hexdigit()) {
3265 return Err(AmpError::validation(
3266 "Unsigned transaction contains invalid hex characters".to_string(),
3267 ));
3268 }
3269
3270 // Attempt to decode hex to validate transaction structure
3271 let tx_bytes = hex::decode(unsigned_tx_hex).map_err(|e| {
3272 AmpError::validation(format!("Failed to decode unsigned transaction hex: {e}"))
3273 })?;
3274
3275 tracing::debug!("Unsigned transaction validation passed, calling signer");
3276
3277 // Call the signer to sign the transaction
3278 let signed_tx_hex = signer
3279 .sign_transaction(unsigned_tx_hex)
3280 .await
3281 .map_err(|e| {
3282 tracing::error!("Transaction signing failed: {}", e);
3283 AmpError::Signer(e).with_context("Failed to sign transaction")
3284 })?;
3285
3286 tracing::debug!(
3287 "Signer returned signed transaction: {}...",
3288 &signed_tx_hex[..std::cmp::min(signed_tx_hex.len(), 64)]
3289 );
3290
3291 // Validate signed transaction format and structure
3292 if signed_tx_hex.is_empty() {
3293 return Err(AmpError::validation(
3294 "Signed transaction hex cannot be empty".to_string(),
3295 ));
3296 }
3297
3298 // Check if signed transaction has valid hex format
3299 if signed_tx_hex.len() % 2 != 0 {
3300 return Err(AmpError::validation(
3301 "Signed transaction hex must have even length".to_string(),
3302 ));
3303 }
3304
3305 // Validate hex characters in signed transaction
3306 if !signed_tx_hex.chars().all(|c| c.is_ascii_hexdigit()) {
3307 return Err(AmpError::validation(
3308 "Signed transaction contains invalid hex characters".to_string(),
3309 ));
3310 }
3311
3312 // Attempt to decode signed transaction to validate structure
3313 let signed_tx_bytes = hex::decode(&signed_tx_hex).map_err(|e| {
3314 AmpError::validation(format!("Failed to decode signed transaction hex: {e}"))
3315 })?;
3316
3317 // Basic validation: signed transaction should be at least as long as unsigned
3318 // (signatures add data, so signed tx should be larger or equal)
3319 if signed_tx_bytes.len() < tx_bytes.len() {
3320 return Err(AmpError::validation(
3321 "Signed transaction is shorter than unsigned transaction, which is invalid"
3322 .to_string(),
3323 ));
3324 }
3325
3326 // Additional validation: check that the transaction structure is reasonable
3327 // Minimum transaction size for Elements (very basic check)
3328 if signed_tx_bytes.len() < MIN_TX_SIZE {
3329 return Err(AmpError::validation(format!(
3330 "Signed transaction does not meet minimum size ({} bytes), minimum is {} bytes",
3331 signed_tx_bytes.len(),
3332 MIN_TX_SIZE
3333 )));
3334 }
3335
3336 tracing::info!(
3337 "Transaction signed successfully - unsigned: {} bytes, signed: {} bytes",
3338 tx_bytes.len(),
3339 signed_tx_bytes.len()
3340 );
3341
3342 Ok(signed_tx_hex)
3343 }
3344
3345 /// Signs and broadcasts a transaction in a single operation
3346 ///
3347 /// This is a convenience method that combines transaction signing and broadcasting.
3348 /// It performs the complete workflow of signing an unsigned transaction and
3349 /// immediately broadcasting it to the network.
3350 ///
3351 /// # Arguments
3352 /// * `unsigned_tx_hex` - The unsigned transaction in hexadecimal format
3353 /// * `signer` - Implementation of the Signer trait for transaction signing
3354 ///
3355 /// # Returns
3356 /// Returns the transaction ID of the broadcast transaction
3357 ///
3358 /// # Errors
3359 /// Returns an error if signing or broadcasting fails
3360 ///
3361 /// # Examples
3362 /// ```no_run
3363 /// # use amp_rs::{ElementsRpc, signer::{Signer, LwkSoftwareSigner}};
3364 /// # #[tokio::main]
3365 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3366 /// let rpc = ElementsRpc::from_env()?;
3367 /// let (_, signer) = LwkSoftwareSigner::generate_new()?;
3368 /// let unsigned_tx = "020000000001..."; // Unsigned transaction hex
3369 /// let txid = rpc.sign_and_broadcast_transaction(unsigned_tx, &signer).await?;
3370 /// println!("Transaction broadcast with ID: {}", txid);
3371 /// # Ok(())
3372 /// # }
3373 /// ```
3374 pub async fn sign_and_broadcast_transaction(
3375 &self,
3376 unsigned_tx_hex: &str,
3377 signer: &dyn crate::signer::Signer,
3378 ) -> Result<String, AmpError> {
3379 tracing::info!("Signing and broadcasting transaction");
3380
3381 // Sign the transaction
3382 let signed_tx_hex = self
3383 .sign_transaction(unsigned_tx_hex, signer)
3384 .await
3385 .map_err(|e| e.with_context("Failed during transaction signing phase"))?;
3386
3387 // Broadcast the signed transaction
3388 let txid = self
3389 .send_raw_transaction(&signed_tx_hex)
3390 .await
3391 .map_err(|e| e.with_context("Failed during transaction broadcast phase"))?;
3392
3393 tracing::info!("Successfully signed and broadcast transaction: {}", txid);
3394 Ok(txid)
3395 }
3396
3397 /// Signs and broadcasts a transaction with UTXO information for proper PSBT construction
3398 ///
3399 /// This method provides UTXO information to the signer for proper PSBT construction,
3400 /// which is required for confidential transactions where the signer needs to know
3401 /// the previous transaction outputs being spent.
3402 ///
3403 /// # Arguments
3404 /// * `unsigned_tx_hex` - The unsigned transaction in hexadecimal format
3405 /// * `utxos` - Vector of UTXOs being spent in the transaction
3406 /// * `signer` - Implementation of the Signer trait for transaction signing
3407 ///
3408 /// # Returns
3409 /// Returns the transaction ID of the broadcast transaction
3410 ///
3411 /// # Errors
3412 /// Returns an error if signing or broadcasting fails
3413 #[allow(clippy::cognitive_complexity)]
3414 pub async fn sign_and_broadcast_transaction_with_utxos(
3415 &self,
3416 unsigned_tx_hex: &str,
3417 utxos: &[Unspent],
3418 signer: &dyn crate::signer::Signer,
3419 ) -> Result<String, AmpError> {
3420 tracing::info!(
3421 "Signing and broadcasting transaction with {} UTXOs",
3422 utxos.len()
3423 );
3424
3425 // Try to use the enhanced signing method if the signer supports it
3426 let signed_tx_hex = if let Some(lwk_signer) = signer
3427 .as_any()
3428 .downcast_ref::<crate::signer::LwkSoftwareSigner>(
3429 ) {
3430 // Use the enhanced signing method with UTXO information
3431 tracing::debug!("Using LWK signer with UTXO information");
3432 lwk_signer
3433 .sign_transaction_with_utxos(unsigned_tx_hex, utxos)
3434 .await
3435 .map_err(|e| {
3436 AmpError::Signer(e)
3437 .with_context("Failed during enhanced transaction signing phase")
3438 })?
3439 } else {
3440 // Fall back to standard signing method
3441 tracing::debug!("Using standard signing method (no UTXO information)");
3442 self.sign_transaction(unsigned_tx_hex, signer)
3443 .await
3444 .map_err(|e| e.with_context("Failed during transaction signing phase"))?
3445 };
3446
3447 // Broadcast the signed transaction
3448 let txid = self
3449 .send_raw_transaction(&signed_tx_hex)
3450 .await
3451 .map_err(|e| e.with_context("Failed during transaction broadcast phase"))?;
3452
3453 tracing::info!("Successfully signed and broadcast transaction: {}", txid);
3454 Ok(txid)
3455 }
3456
3457 /// Collects change data from a confirmed transaction for distribution confirmation
3458 ///
3459 /// This method queries the Elements node to find change UTXOs from a specific transaction
3460 /// that belong to the specified asset. It's used after a distribution transaction is
3461 /// confirmed to collect the change outputs for the final confirmation API call.
3462 ///
3463 /// # Arguments
3464 /// * `asset_id` - The asset ID to filter change UTXOs for
3465 /// * `txid` - The transaction ID to filter change UTXOs from
3466 ///
3467 /// # Returns
3468 /// Returns a vector of Unspent UTXOs that represent change outputs from the transaction.
3469 /// Returns an empty vector if no change outputs exist for the specified asset and transaction.
3470 ///
3471 /// # Errors
3472 /// Returns an error if the RPC call fails or if there are issues querying the Elements node
3473 ///
3474 /// # Examples
3475 /// ```no_run
3476 /// # use amp_rs::ElementsRpc;
3477 /// # #[tokio::main]
3478 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3479 /// let rpc = ElementsRpc::from_env()?;
3480 /// let change_data = rpc.collect_change_data(
3481 /// "asset_id_hex",
3482 /// "transaction_id_hex",
3483 /// &rpc,
3484 /// "wallet_name"
3485 /// ).await?;
3486 ///
3487 /// if change_data.is_empty() {
3488 /// println!("No change outputs found for this transaction");
3489 /// } else {
3490 /// println!("Found {} change outputs", change_data.len());
3491 /// }
3492 /// # Ok(())
3493 /// # }
3494 /// ```
3495 #[allow(clippy::cognitive_complexity)]
3496 pub async fn collect_change_data(
3497 &self,
3498 asset_id: &str,
3499 txid: &str,
3500 node_rpc: &Self,
3501 wallet_name: &str,
3502 ) -> Result<Vec<Unspent>, AmpError> {
3503 tracing::debug!(
3504 "Collecting change data for asset {} from transaction {}",
3505 asset_id,
3506 txid
3507 );
3508
3509 // Use the raw listunspent RPC call to get full blinding information
3510 // This is essential for confidential transactions as the AMP API requires
3511 // both amountblinder and assetblinder fields
3512 let all_utxos = node_rpc
3513 .list_unspent_with_blinding_data(wallet_name)
3514 .await
3515 .map_err(|e| {
3516 e.with_context(
3517 "Failed to query unspent outputs with blinding data for change data collection",
3518 )
3519 })?;
3520
3521 // Filter UTXOs to only include those from the specified transaction
3522 let change_utxos: Vec<Unspent> = all_utxos
3523 .into_iter()
3524 .filter(|utxo| {
3525 // Match UTXOs that:
3526 // 1. Come from the specified transaction (txid matches)
3527 // 2. Are for the correct asset
3528 // 3. Are spendable
3529 utxo.txid == txid && utxo.asset == asset_id && utxo.spendable
3530 })
3531 .collect();
3532
3533 tracing::info!(
3534 "Collected {} change UTXOs for asset {} from transaction {}",
3535 change_utxos.len(),
3536 asset_id,
3537 txid
3538 );
3539
3540 // Log details of found change UTXOs for debugging
3541 for (index, utxo) in change_utxos.iter().enumerate() {
3542 tracing::debug!(
3543 "Change UTXO {}: txid={}, vout={}, amount={}, asset={}, amountblinder={:?}, assetblinder={:?}",
3544 index + 1,
3545 utxo.txid,
3546 utxo.vout,
3547 utxo.amount,
3548 utxo.asset,
3549 utxo.amountblinder,
3550 utxo.assetblinder
3551 );
3552 }
3553
3554 // Handle the case where no change outputs exist
3555 if change_utxos.is_empty() {
3556 tracing::info!(
3557 "No change outputs found for asset {} in transaction {} - this is normal if all funds were distributed",
3558 asset_id,
3559 txid
3560 );
3561 }
3562
3563 Ok(change_utxos)
3564 }
3565
3566 /// Lists unspent outputs with full blinding data for confidential transactions
3567 ///
3568 /// This method calls the raw `listunspent` RPC to get complete UTXO information
3569 /// including blinding data (amountblinder and assetblinder) which is required
3570 /// for confidential transaction confirmation with the AMP API.
3571 ///
3572 /// # Arguments
3573 /// * `wallet_name` - Name of the Elements wallet to query
3574 ///
3575 /// # Returns
3576 /// Returns a vector of `Unspent` structs with complete blinding information
3577 ///
3578 /// # Errors
3579 /// Returns an error if the RPC call fails
3580 ///
3581 /// # Examples
3582 /// ```no_run
3583 /// # use amp_rs::ElementsRpc;
3584 /// # #[tokio::main]
3585 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3586 /// let rpc = ElementsRpc::from_env()?;
3587 /// let utxos = rpc.list_unspent_with_blinding_data("wallet_name").await?;
3588 /// for utxo in utxos {
3589 /// println!("UTXO: {} with blinders: {:?}, {:?}",
3590 /// utxo.txid, utxo.amountblinder, utxo.assetblinder);
3591 /// }
3592 /// # Ok(())
3593 /// # }
3594 /// ```
3595 pub async fn list_unspent_with_blinding_data(
3596 &self,
3597 wallet_name: &str,
3598 ) -> Result<Vec<Unspent>, AmpError> {
3599 tracing::debug!(
3600 "Listing unspent outputs with blinding data for wallet: {}",
3601 wallet_name
3602 );
3603
3604 // First load the wallet to ensure it's available
3605 self.load_wallet(wallet_name).await?;
3606
3607 // Call listunspent with parameters to get all UTXOs
3608 // Parameters: minconf, maxconf, addresses, include_unsafe, query_options
3609 let params = serde_json::json!([
3610 0, // minconf: include unconfirmed
3611 9_999_999, // maxconf: include all confirmed
3612 [], // addresses: empty array means all addresses
3613 true, // include_unsafe: include unconfirmed transactions
3614 {} // query_options: empty object for default options
3615 ]);
3616
3617 // Use the wallet-specific RPC endpoint
3618 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3619
3620 let request = RpcRequest {
3621 jsonrpc: "1.0".to_string(),
3622 id: "amp-client".to_string(),
3623 method: "listunspent".to_string(),
3624 params,
3625 };
3626
3627 let response = self
3628 .client
3629 .post(&wallet_url)
3630 .basic_auth(&self.username, Some(&self.password))
3631 .json(&request)
3632 .send()
3633 .await
3634 .map_err(|e| AmpError::rpc(format!("Failed to send listunspent RPC request: {e}")))?;
3635
3636 if !response.status().is_success() {
3637 let status = response.status();
3638 let error_body = response
3639 .text()
3640 .await
3641 .unwrap_or_else(|_| "Unable to read error body".to_string());
3642 return Err(AmpError::rpc(format!(
3643 "Listunspent RPC request failed with status: {status} - Body: {error_body}"
3644 )));
3645 }
3646
3647 let rpc_response: RpcResponse<Vec<Unspent>> = response
3648 .json()
3649 .await
3650 .map_err(|e| AmpError::rpc(format!("Failed to parse listunspent RPC response: {e}")))?;
3651
3652 if let Some(error) = rpc_response.error {
3653 return Err(AmpError::rpc(format!(
3654 "Listunspent RPC error: {} (code: {})",
3655 error.message, error.code
3656 )));
3657 }
3658
3659 let utxos = rpc_response.result.unwrap_or_default();
3660 tracing::info!(
3661 "Retrieved {} UTXOs with blinding data from wallet {}",
3662 utxos.len(),
3663 wallet_name
3664 );
3665
3666 Ok(utxos)
3667 }
3668
3669 /// Creates a standard wallet in Elements (Elements-first approach)
3670 ///
3671 /// This method creates a new standard wallet in the Elements node that can generate
3672 /// addresses and private keys. This is part of the Elements-first approach where
3673 /// we create the wallet in Elements first, then export keys to LWK.
3674 ///
3675 /// # Arguments
3676 /// * `wallet_name` - Name for the new wallet
3677 ///
3678 /// # Errors
3679 /// Returns an error if the RPC call fails
3680 ///
3681 /// # Examples
3682 /// ```no_run
3683 /// # use amp_rs::ElementsRpc;
3684 /// # #[tokio::main]
3685 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3686 /// let rpc = ElementsRpc::from_env()?;
3687 /// rpc.create_elements_wallet("test_wallet").await?;
3688 /// # Ok(())
3689 /// # }
3690 /// ```
3691 pub async fn create_elements_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
3692 let params = serde_json::json!([wallet_name]);
3693
3694 let _result: serde_json::Value = self.rpc_call("createwallet", params).await?;
3695
3696 tracing::info!("Successfully created Elements wallet: {}", wallet_name);
3697 Ok(())
3698 }
3699
3700 /// Get a new address from an Elements wallet
3701 ///
3702 /// This method requests a new address from the specified Elements wallet.
3703 /// The address will be generated by Elements and can be used for receiving funds.
3704 /// Defaults to native segwit (bech32) addresses for optimal compatibility.
3705 ///
3706 /// # Arguments
3707 /// * `wallet_name` - Name of the wallet to get address from
3708 /// * `address_type` - Optional address type ("bech32", "legacy", "p2sh-segwit"). Defaults to "bech32"
3709 ///
3710 /// # Errors
3711 /// Returns an error if the RPC call fails or the response format is unexpected
3712 ///
3713 /// # Examples
3714 /// ```no_run
3715 /// # use amp_rs::ElementsRpc;
3716 /// # #[tokio::main]
3717 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3718 /// let rpc = ElementsRpc::from_env()?;
3719 ///
3720 /// // Generate native segwit address (default)
3721 /// let address = rpc.get_new_address("test_wallet", None).await?;
3722 ///
3723 /// // Or explicitly request native segwit
3724 /// let bech32_address = rpc.get_new_address("test_wallet", Some("bech32")).await?;
3725 ///
3726 /// println!("Native segwit address: {}", address);
3727 /// # Ok(())
3728 /// # }
3729 /// ```
3730 pub async fn get_new_address(
3731 &self,
3732 wallet_name: &str,
3733 address_type: Option<&str>,
3734 ) -> Result<String, AmpError> {
3735 // First load the wallet to ensure it's available
3736 self.load_wallet(wallet_name).await?;
3737
3738 // Set default to native segwit (bech32) for Elements
3739 let addr_type = address_type.unwrap_or("bech32");
3740
3741 // For Elements, we need to use the correct parameters for getnewaddress
3742 // getnewaddress [label] [address_type]
3743 let params = serde_json::json!(["", addr_type]);
3744
3745 // Create RPC request for getnewaddress
3746 let request = RpcRequest {
3747 jsonrpc: "1.0".to_string(),
3748 id: "amp-client".to_string(),
3749 method: "getnewaddress".to_string(),
3750 params,
3751 };
3752
3753 // Use the wallet-specific RPC endpoint
3754 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3755
3756 let response = self
3757 .client
3758 .post(&wallet_url)
3759 .basic_auth(&self.username, Some(&self.password))
3760 .json(&request)
3761 .send()
3762 .await
3763 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3764
3765 if !response.status().is_success() {
3766 let status = response.status();
3767 let error_body = response
3768 .text()
3769 .await
3770 .unwrap_or_else(|_| "Unable to read error body".to_string());
3771 return Err(AmpError::rpc(format!(
3772 "RPC request failed with status: {status} - Body: {error_body}"
3773 )));
3774 }
3775
3776 let rpc_response: RpcResponse<serde_json::Value> = response
3777 .json()
3778 .await
3779 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
3780
3781 if let Some(error) = rpc_response.error {
3782 return Err(AmpError::rpc(format!(
3783 "RPC error getting new address: {} (code: {})",
3784 error.message, error.code
3785 )));
3786 }
3787
3788 if let Some(result) = rpc_response.result {
3789 if let Some(address) = result.as_str() {
3790 tracing::info!("Generated new {} address: {}", addr_type, address);
3791 return Ok(address.to_string());
3792 }
3793 }
3794
3795 Err(AmpError::rpc(format!(
3796 "Failed to get new address from wallet '{wallet_name}': unexpected response format"
3797 )))
3798 }
3799
3800 /// Get the confidential version of an address from Elements wallet
3801 ///
3802 /// This method takes a regular (unconfidential) address and returns its confidential
3803 /// counterpart, which includes blinding keys for confidential transactions.
3804 ///
3805 /// # Arguments
3806 ///
3807 /// * `wallet_name` - Name of the Elements wallet
3808 /// * `address` - The unconfidential address to get info for
3809 ///
3810 /// # Returns
3811 ///
3812 /// Returns the confidential address string
3813 ///
3814 /// # Example
3815 ///
3816 /// ```no_run
3817 /// # use amp_rs::ElementsRpc;
3818 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
3819 /// let rpc = ElementsRpc::from_env()?;
3820 /// let unconfidential_address = "tex1q...";
3821 /// // Note: This would need to be called in an async context
3822 /// // let confidential_address = rpc.get_confidential_address("test_wallet", unconfidential_address).await?;
3823 /// // println!("Confidential address: {}", confidential_address);
3824 /// # Ok(())
3825 /// # }
3826 /// ```
3827 /// Gets the confidential address for a given unconfidential address from a wallet
3828 ///
3829 /// # Errors
3830 /// Returns an error if the RPC call fails or the response format is unexpected
3831 pub async fn get_confidential_address(
3832 &self,
3833 wallet_name: &str,
3834 address: &str,
3835 ) -> Result<String, AmpError> {
3836 // First load the wallet to ensure it's available
3837 self.load_wallet(wallet_name).await?;
3838
3839 let params = serde_json::json!([address]);
3840
3841 // Create RPC request for getaddressinfo
3842 let request = RpcRequest {
3843 jsonrpc: "1.0".to_string(),
3844 id: "amp-client".to_string(),
3845 method: "getaddressinfo".to_string(),
3846 params,
3847 };
3848
3849 // Use the wallet-specific RPC endpoint
3850 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3851
3852 let response = self
3853 .client
3854 .post(&wallet_url)
3855 .basic_auth(&self.username, Some(&self.password))
3856 .json(&request)
3857 .send()
3858 .await
3859 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3860
3861 if !response.status().is_success() {
3862 let status = response.status();
3863 let error_body = response
3864 .text()
3865 .await
3866 .unwrap_or_else(|_| "Unable to read error body".to_string());
3867 return Err(AmpError::rpc(format!(
3868 "RPC request failed with status: {status} - Body: {error_body}"
3869 )));
3870 }
3871
3872 let rpc_response: RpcResponse<serde_json::Value> = response
3873 .json()
3874 .await
3875 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
3876
3877 if let Some(error) = rpc_response.error {
3878 return Err(AmpError::rpc(format!(
3879 "RPC error getting address info: {} (code: {})",
3880 error.message, error.code
3881 )));
3882 }
3883
3884 if let Some(result) = rpc_response.result {
3885 if let Some(confidential_address) = result.get("confidential").and_then(|v| v.as_str())
3886 {
3887 tracing::info!("Retrieved confidential address for: {}", address);
3888 return Ok(confidential_address.to_string());
3889 }
3890 }
3891
3892 Err(AmpError::rpc(format!(
3893 "Failed to get confidential address for '{address}': unexpected response format"
3894 )))
3895 }
3896
3897 /// Get the private key for an address from Elements wallet
3898 ///
3899 /// This method exports the private key for a specific address from the Elements wallet.
3900 /// The private key can then be imported into LWK for signing.
3901 ///
3902 /// Note: This is a simplified implementation that returns a placeholder private key.
3903 /// For production use, implement proper wallet-specific RPC calls.
3904 ///
3905 /// # Arguments
3906 /// * `wallet_name` - Name of the wallet containing the address
3907 /// * `address` - The address to get the private key for
3908 ///
3909 /// # Errors
3910 /// Returns an error if the RPC call fails
3911 ///
3912 /// # Examples
3913 /// ```no_run
3914 /// # use amp_rs::ElementsRpc;
3915 /// # #[tokio::main]
3916 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3917 /// let rpc = ElementsRpc::from_env()?;
3918 /// let address = rpc.get_new_address("test_wallet", None).await?;
3919 /// let private_key = rpc.dump_private_key("test_wallet", &address).await?;
3920 /// println!("Private key: {}", private_key);
3921 /// # Ok(())
3922 /// # }
3923 /// ```
3924 pub async fn dump_private_key(
3925 &self,
3926 wallet_name: &str,
3927 address: &str,
3928 ) -> Result<String, AmpError> {
3929 // First load the wallet to ensure it's available
3930 self.load_wallet(wallet_name).await?;
3931
3932 let params = serde_json::json!([address]);
3933
3934 // Create RPC request for dumpprivkey
3935 let request = RpcRequest {
3936 jsonrpc: "1.0".to_string(),
3937 id: "amp-client".to_string(),
3938 method: "dumpprivkey".to_string(),
3939 params,
3940 };
3941
3942 // Use the wallet-specific RPC endpoint
3943 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3944
3945 let response = self
3946 .client
3947 .post(&wallet_url)
3948 .basic_auth(&self.username, Some(&self.password))
3949 .json(&request)
3950 .send()
3951 .await
3952 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3953
3954 if !response.status().is_success() {
3955 return Err(AmpError::rpc(format!(
3956 "RPC request failed with status: {}",
3957 response.status()
3958 )));
3959 }
3960
3961 let rpc_response: RpcResponse<serde_json::Value> = response
3962 .json()
3963 .await
3964 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
3965
3966 if let Some(error) = rpc_response.error {
3967 return Err(AmpError::rpc(format!(
3968 "RPC error dumping private key: {} (code: {})",
3969 error.message, error.code
3970 )));
3971 }
3972
3973 if let Some(result) = rpc_response.result {
3974 if let Some(private_key) = result.as_str() {
3975 tracing::info!("Successfully exported private key for address: {}", address);
3976 return Ok(private_key.to_string());
3977 }
3978 }
3979
3980 Err(AmpError::rpc(format!(
3981 "Failed to dump private key for address '{address}': unexpected response format"
3982 )))
3983 }
3984
3985 /// Creates a descriptor wallet in Elements
3986 ///
3987 /// # Arguments
3988 /// * `wallet_name` - Name for the new wallet
3989 ///
3990 /// # Errors
3991 /// Returns an error if the RPC call fails
3992 ///
3993 /// # Examples
3994 /// ```no_run
3995 /// # use amp_rs::ElementsRpc;
3996 /// # #[tokio::main]
3997 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3998 /// let rpc = ElementsRpc::from_env()?;
3999 /// rpc.create_descriptor_wallet("test_wallet").await?;
4000 /// # Ok(())
4001 /// # }
4002 /// ```
4003 pub async fn create_descriptor_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
4004 let params = serde_json::json!([wallet_name, true]); // true enables descriptors
4005
4006 let _result: serde_json::Value = self.rpc_call("createwallet", params).await?;
4007
4008 tracing::info!("Successfully created descriptor wallet: {}", wallet_name);
4009 Ok(())
4010 }
4011
4012 /// Imports a single descriptor into an Elements wallet
4013 ///
4014 /// This method imports a descriptor that enables the wallet to scan and recognize
4015 /// addresses/UTXOs from a mnemonic. For LWK descriptors with `<0;1>/*` format,
4016 /// a single descriptor covers both receive and change addresses.
4017 ///
4018 /// # Arguments
4019 /// * `wallet_name` - Name of the wallet to import descriptor into
4020 /// * `descriptor` - The descriptor to import
4021 ///
4022 /// # Errors
4023 /// Returns an error if the RPC call fails
4024 ///
4025 /// # Examples
4026 /// ```no_run
4027 /// # use amp_rs::ElementsRpc;
4028 /// # #[tokio::main]
4029 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4030 /// let rpc = ElementsRpc::from_env()?;
4031 /// let descriptor = "ct(slip77(...),elwpkh([...]/84h/1h/0h]tpub.../<0;1>/*))#checksum";
4032 /// rpc.import_descriptor("test_wallet", descriptor).await?;
4033 /// # Ok(())
4034 /// # }
4035 /// ```
4036 pub async fn import_descriptor(
4037 &self,
4038 wallet_name: &str,
4039 descriptor: &str,
4040 ) -> Result<(), AmpError> {
4041 tracing::info!("Importing descriptor into wallet: {}", wallet_name);
4042 tracing::debug!("Descriptor: {}", descriptor);
4043
4044 let descriptors = serde_json::json!([
4045 {
4046 "desc": descriptor,
4047 "timestamp": "now",
4048 "active": true,
4049 "internal": false // For LWK descriptors with <0;1>/*, this covers both chains
4050 }
4051 ]);
4052
4053 // Use -rpcwallet parameter to specify the wallet
4054 let request = RpcRequest {
4055 jsonrpc: "1.0".to_string(),
4056 id: "amp-client".to_string(),
4057 method: "importdescriptors".to_string(),
4058 params: descriptors,
4059 };
4060
4061 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4062
4063 let response = self
4064 .client
4065 .post(&wallet_url)
4066 .basic_auth(&self.username, Some(&self.password))
4067 .json(&request)
4068 .send()
4069 .await
4070 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4071
4072 if !response.status().is_success() {
4073 return Err(AmpError::rpc(format!(
4074 "RPC request failed with status: {}",
4075 response.status()
4076 )));
4077 }
4078
4079 let rpc_response: RpcResponse<serde_json::Value> = response
4080 .json()
4081 .await
4082 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4083
4084 if let Some(error) = rpc_response.error {
4085 return Err(AmpError::rpc(format!(
4086 "RPC error {}: {}",
4087 error.code, error.message
4088 )));
4089 }
4090
4091 let result = rpc_response
4092 .result
4093 .ok_or_else(|| AmpError::rpc("RPC response missing result field".to_string()))?;
4094
4095 // Check if descriptor was imported successfully
4096 if let Some(results) = result.as_array() {
4097 if let Some(result) = results.first() {
4098 if let Some(success) = result.get("success").and_then(serde_json::Value::as_bool) {
4099 if !success {
4100 let error_msg = result
4101 .get("error")
4102 .and_then(|e| e.get("message"))
4103 .and_then(|m| m.as_str())
4104 .unwrap_or("Unknown error");
4105 return Err(AmpError::rpc(format!(
4106 "Failed to import descriptor: {error_msg}"
4107 )));
4108 }
4109 } else {
4110 return Err(AmpError::rpc(format!(
4111 "Invalid response format for descriptor import: {result:?}"
4112 )));
4113 }
4114 }
4115 } else {
4116 return Err(AmpError::rpc(format!(
4117 "Invalid response format: expected array, got {result:?}"
4118 )));
4119 }
4120
4121 tracing::info!(
4122 "Successfully imported descriptor into wallet: {}",
4123 wallet_name
4124 );
4125 Ok(())
4126 }
4127
4128 /// Imports descriptors into an Elements wallet (legacy method for compatibility)
4129 ///
4130 /// This method imports descriptors that enable the wallet to scan and recognize
4131 /// addresses/UTXOs from a mnemonic. If both descriptors are the same (as with LWK
4132 /// descriptors using `<0;1>/*` format), only one descriptor is imported.
4133 ///
4134 /// # Arguments
4135 /// * `wallet_name` - Name of the wallet to import descriptors into
4136 /// * `receive_descriptor` - The receive descriptor
4137 /// * `change_descriptor` - The change descriptor
4138 ///
4139 /// # Errors
4140 /// Returns an error if the RPC call fails
4141 ///
4142 /// # Examples
4143 /// ```no_run
4144 /// # use amp_rs::ElementsRpc;
4145 /// # #[tokio::main]
4146 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4147 /// let rpc = ElementsRpc::from_env()?;
4148 /// let descriptor = "ct(slip77(...),elwpkh([...]/84h/1h/0h]tpub.../<0;1>/*))#checksum";
4149 /// rpc.import_descriptors("test_wallet", descriptor, descriptor).await?;
4150 /// # Ok(())
4151 /// # }
4152 /// ```
4153 #[allow(clippy::cognitive_complexity)]
4154 pub async fn import_descriptors(
4155 &self,
4156 wallet_name: &str,
4157 receive_descriptor: &str,
4158 change_descriptor: &str,
4159 ) -> Result<(), AmpError> {
4160 // If both descriptors are the same (LWK case), import only once
4161 if receive_descriptor == change_descriptor {
4162 return self
4163 .import_descriptor(wallet_name, receive_descriptor)
4164 .await;
4165 }
4166
4167 tracing::info!(
4168 "Importing separate receive and change descriptors into wallet: {}",
4169 wallet_name
4170 );
4171 tracing::debug!("Receive descriptor: {}", receive_descriptor);
4172 tracing::debug!("Change descriptor: {}", change_descriptor);
4173
4174 let descriptors = serde_json::json!([
4175 {
4176 "desc": receive_descriptor,
4177 "timestamp": "now",
4178 "active": true,
4179 "internal": false
4180 },
4181 {
4182 "desc": change_descriptor,
4183 "timestamp": "now",
4184 "active": true,
4185 "internal": true
4186 }
4187 ]);
4188
4189 // Use -rpcwallet parameter to specify the wallet
4190 let request = RpcRequest {
4191 jsonrpc: "1.0".to_string(),
4192 id: "amp-client".to_string(),
4193 method: "importdescriptors".to_string(),
4194 params: descriptors,
4195 };
4196
4197 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4198
4199 let response = self
4200 .client
4201 .post(&wallet_url)
4202 .basic_auth(&self.username, Some(&self.password))
4203 .json(&request)
4204 .send()
4205 .await
4206 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4207
4208 if !response.status().is_success() {
4209 return Err(AmpError::rpc(format!(
4210 "RPC request failed with status: {}",
4211 response.status()
4212 )));
4213 }
4214
4215 let rpc_response: RpcResponse<serde_json::Value> = response
4216 .json()
4217 .await
4218 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4219
4220 if let Some(error) = rpc_response.error {
4221 return Err(AmpError::rpc(format!(
4222 "RPC error {}: {}",
4223 error.code, error.message
4224 )));
4225 }
4226
4227 let result = rpc_response
4228 .result
4229 .ok_or_else(|| AmpError::rpc("RPC response missing result field".to_string()))?;
4230
4231 // Check if both descriptors were imported successfully
4232 if let Some(results) = result.as_array() {
4233 for (i, result) in results.iter().enumerate() {
4234 if let Some(success) = result.get("success").and_then(serde_json::Value::as_bool) {
4235 if !success {
4236 let desc_type = if i == 0 { "receive" } else { "change" };
4237 let error_msg = result
4238 .get("error")
4239 .and_then(|e| e.get("message"))
4240 .and_then(|m| m.as_str())
4241 .unwrap_or("Unknown error");
4242 return Err(AmpError::rpc(format!(
4243 "Failed to import {desc_type} descriptor: {error_msg}"
4244 )));
4245 }
4246 } else {
4247 return Err(AmpError::rpc(format!(
4248 "Invalid response format for descriptor import: {result:?}"
4249 )));
4250 }
4251 }
4252 } else {
4253 return Err(AmpError::rpc(format!(
4254 "Invalid response format: expected array, got {result:?}"
4255 )));
4256 }
4257
4258 tracing::info!(
4259 "Successfully imported descriptors into wallet: {}",
4260 wallet_name
4261 );
4262 Ok(())
4263 }
4264
4265 /// Sets up a wallet with descriptors from a mnemonic
4266 ///
4267 /// This is a convenience method that combines wallet creation and descriptor import.
4268 /// It creates a descriptor wallet and imports the receive and change descriptors
4269 /// generated from the provided mnemonic.
4270 ///
4271 /// # Arguments
4272 /// * `wallet_name` - Name for the new wallet
4273 /// * `receive_descriptor` - The receive descriptor (external chain /0/*)
4274 /// * `change_descriptor` - The change descriptor (internal chain /1/*)
4275 ///
4276 /// # Errors
4277 /// Returns an error if wallet creation or descriptor import fails
4278 ///
4279 /// # Examples
4280 /// ```no_run
4281 /// # use amp_rs::ElementsRpc;
4282 /// # #[tokio::main]
4283 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4284 /// let rpc = ElementsRpc::from_env()?;
4285 /// let receive_desc = "wpkh([d34db33f/84h/1h/0h]xprv.../0/*)#checksum";
4286 /// let change_desc = "wpkh([d34db33f/84h/1h/0h]xprv.../1/*)#checksum";
4287 /// rpc.setup_wallet_with_descriptors("test_wallet", receive_desc, change_desc).await?;
4288 /// # Ok(())
4289 /// # }
4290 /// ```
4291 #[allow(clippy::cognitive_complexity)]
4292 pub async fn setup_wallet_with_descriptors(
4293 &self,
4294 wallet_name: &str,
4295 receive_descriptor: &str,
4296 change_descriptor: &str,
4297 ) -> Result<(), AmpError> {
4298 tracing::info!("Setting up wallet with descriptors: {}", wallet_name);
4299
4300 // Try to create the wallet (may fail if it already exists)
4301 match self.create_descriptor_wallet(wallet_name).await {
4302 Ok(()) => {
4303 tracing::info!("Created new descriptor wallet: {}", wallet_name);
4304 }
4305 Err(e) => {
4306 let error_msg = e.to_string();
4307 if error_msg.contains("already exists")
4308 || error_msg.contains("Database already exists")
4309 {
4310 tracing::info!(
4311 "Wallet {} already exists, proceeding with descriptor import",
4312 wallet_name
4313 );
4314 } else {
4315 return Err(e);
4316 }
4317 }
4318 }
4319
4320 // Import the descriptors
4321 self.import_descriptors(wallet_name, receive_descriptor, change_descriptor)
4322 .await?;
4323
4324 tracing::info!(
4325 "Successfully set up wallet with descriptors: {}",
4326 wallet_name
4327 );
4328 Ok(())
4329 }
4330
4331 /// Exports a wallet to a file using dumpwallet RPC
4332 ///
4333 /// # Arguments
4334 /// * `wallet_name` - Name of the wallet to export
4335 /// * `file_path` - Path where the wallet dump file will be created
4336 ///
4337 /// # Errors
4338 /// Returns an error if the RPC call fails or the wallet cannot be exported
4339 ///
4340 /// # Examples
4341 /// ```no_run
4342 /// # use amp_rs::ElementsRpc;
4343 /// # #[tokio::main]
4344 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4345 /// let rpc = ElementsRpc::from_env()?;
4346 /// rpc.dump_wallet("my_wallet", "/tmp/wallet_export.dat").await?;
4347 /// # Ok(())
4348 /// # }
4349 /// ```
4350 pub async fn dump_wallet(&self, wallet_name: &str, file_path: &str) -> Result<(), AmpError> {
4351 // First load the wallet to ensure it's available
4352 self.load_wallet(wallet_name).await?;
4353
4354 let params = serde_json::json!([file_path]);
4355
4356 // Create RPC request for dumpwallet
4357 let request = RpcRequest {
4358 jsonrpc: "1.0".to_string(),
4359 id: "amp-client".to_string(),
4360 method: "dumpwallet".to_string(),
4361 params,
4362 };
4363
4364 // Use the wallet-specific RPC endpoint
4365 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4366
4367 let response = self
4368 .client
4369 .post(&wallet_url)
4370 .basic_auth(&self.username, Some(&self.password))
4371 .json(&request)
4372 .send()
4373 .await
4374 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4375
4376 if !response.status().is_success() {
4377 return Err(AmpError::rpc(format!(
4378 "RPC request failed with status: {}",
4379 response.status()
4380 )));
4381 }
4382
4383 let rpc_response: RpcResponse<serde_json::Value> = response
4384 .json()
4385 .await
4386 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4387
4388 if let Some(error) = rpc_response.error {
4389 return Err(AmpError::rpc(format!(
4390 "RPC error dumping wallet: {} (code: {})",
4391 error.message, error.code
4392 )));
4393 }
4394
4395 tracing::info!(
4396 "Successfully exported wallet {} to {}",
4397 wallet_name,
4398 file_path
4399 );
4400 Ok(())
4401 }
4402
4403 /// Imports a wallet from a file using importwallet RPC
4404 ///
4405 /// # Arguments
4406 /// * `wallet_name` - Name of the wallet to import into
4407 /// * `file_path` - Path to the wallet dump file to import
4408 ///
4409 /// # Errors
4410 /// Returns an error if the RPC call fails or the wallet cannot be imported
4411 ///
4412 /// # Examples
4413 /// ```no_run
4414 /// # use amp_rs::ElementsRpc;
4415 /// # #[tokio::main]
4416 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4417 /// let rpc = ElementsRpc::from_env()?;
4418 /// rpc.import_wallet("my_wallet", "/tmp/wallet_export.dat").await?;
4419 /// # Ok(())
4420 /// # }
4421 /// ```
4422 pub async fn import_wallet(&self, wallet_name: &str, file_path: &str) -> Result<(), AmpError> {
4423 // First load the wallet to ensure it's available
4424 self.load_wallet(wallet_name).await?;
4425
4426 let params = serde_json::json!([file_path]);
4427
4428 // Create RPC request for importwallet
4429 let request = RpcRequest {
4430 jsonrpc: "1.0".to_string(),
4431 id: "amp-client".to_string(),
4432 method: "importwallet".to_string(),
4433 params,
4434 };
4435
4436 // Use the wallet-specific RPC endpoint
4437 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4438
4439 let response = self
4440 .client
4441 .post(&wallet_url)
4442 .basic_auth(&self.username, Some(&self.password))
4443 .json(&request)
4444 .send()
4445 .await
4446 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4447
4448 if !response.status().is_success() {
4449 return Err(AmpError::rpc(format!(
4450 "RPC request failed with status: {}",
4451 response.status()
4452 )));
4453 }
4454
4455 let rpc_response: RpcResponse<serde_json::Value> = response
4456 .json()
4457 .await
4458 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4459
4460 if let Some(error) = rpc_response.error {
4461 return Err(AmpError::rpc(format!(
4462 "RPC error importing wallet: {} (code: {})",
4463 error.message, error.code
4464 )));
4465 }
4466
4467 tracing::info!(
4468 "Successfully imported wallet {} from {}",
4469 wallet_name,
4470 file_path
4471 );
4472 Ok(())
4473 }
4474
4475 /// Exports a blinding key for a confidential address using dumpblindingkey RPC
4476 ///
4477 /// # Arguments
4478 /// * `wallet_name` - Name of the wallet containing the address
4479 /// * `address` - The confidential address to export the blinding key for
4480 ///
4481 /// # Errors
4482 /// Returns an error if the RPC call fails or the address doesn't have a blinding key
4483 ///
4484 /// # Examples
4485 /// ```no_run
4486 /// # use amp_rs::ElementsRpc;
4487 /// # #[tokio::main]
4488 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4489 /// let rpc = ElementsRpc::from_env()?;
4490 /// let key = rpc.dump_blinding_key("my_wallet", "VTpz...").await?;
4491 /// println!("Blinding key: {}", key);
4492 /// # Ok(())
4493 /// # }
4494 /// ```
4495 pub async fn dump_blinding_key(
4496 &self,
4497 wallet_name: &str,
4498 address: &str,
4499 ) -> Result<String, AmpError> {
4500 // First load the wallet to ensure it's available
4501 self.load_wallet(wallet_name).await?;
4502
4503 let params = serde_json::json!([address]);
4504
4505 // Create RPC request for dumpblindingkey
4506 let request = RpcRequest {
4507 jsonrpc: "1.0".to_string(),
4508 id: "amp-client".to_string(),
4509 method: "dumpblindingkey".to_string(),
4510 params,
4511 };
4512
4513 // Use the wallet-specific RPC endpoint
4514 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4515
4516 let response = self
4517 .client
4518 .post(&wallet_url)
4519 .basic_auth(&self.username, Some(&self.password))
4520 .json(&request)
4521 .send()
4522 .await
4523 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4524
4525 if !response.status().is_success() {
4526 return Err(AmpError::rpc(format!(
4527 "RPC request failed with status: {}",
4528 response.status()
4529 )));
4530 }
4531
4532 let rpc_response: RpcResponse<serde_json::Value> = response
4533 .json()
4534 .await
4535 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4536
4537 if let Some(error) = rpc_response.error {
4538 return Err(AmpError::rpc(format!(
4539 "RPC error dumping blinding key: {} (code: {})",
4540 error.message, error.code
4541 )));
4542 }
4543
4544 if let Some(result) = rpc_response.result {
4545 if let Some(blinding_key) = result.as_str() {
4546 tracing::info!(
4547 "Successfully exported blinding key for address: {}",
4548 address
4549 );
4550 return Ok(blinding_key.to_string());
4551 }
4552 }
4553
4554 Err(AmpError::rpc(format!(
4555 "Failed to dump blinding key for address '{address}': unexpected response format"
4556 )))
4557 }
4558
4559 /// Imports a blinding key for a confidential address using importblindingkey RPC
4560 ///
4561 /// # Arguments
4562 /// * `wallet_name` - Name of the wallet to import the blinding key into
4563 /// * `address` - The confidential address to import the blinding key for
4564 /// * `blinding_key` - The blinding key to import
4565 ///
4566 /// # Errors
4567 /// Returns an error if the RPC call fails or the blinding key cannot be imported
4568 ///
4569 /// # Examples
4570 /// ```no_run
4571 /// # use amp_rs::ElementsRpc;
4572 /// # #[tokio::main]
4573 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4574 /// let rpc = ElementsRpc::from_env()?;
4575 /// rpc.import_blinding_key("my_wallet", "VTpz...", "blinding_key_hex").await?;
4576 /// # Ok(())
4577 /// # }
4578 /// ```
4579 pub async fn import_blinding_key(
4580 &self,
4581 wallet_name: &str,
4582 address: &str,
4583 blinding_key: &str,
4584 ) -> Result<(), AmpError> {
4585 // First load the wallet to ensure it's available
4586 self.load_wallet(wallet_name).await?;
4587
4588 let params = serde_json::json!([address, blinding_key]);
4589
4590 // Create RPC request for importblindingkey
4591 let request = RpcRequest {
4592 jsonrpc: "1.0".to_string(),
4593 id: "amp-client".to_string(),
4594 method: "importblindingkey".to_string(),
4595 params,
4596 };
4597
4598 // Use the wallet-specific RPC endpoint
4599 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4600
4601 let response = self
4602 .client
4603 .post(&wallet_url)
4604 .basic_auth(&self.username, Some(&self.password))
4605 .json(&request)
4606 .send()
4607 .await
4608 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4609
4610 if !response.status().is_success() {
4611 return Err(AmpError::rpc(format!(
4612 "RPC request failed with status: {}",
4613 response.status()
4614 )));
4615 }
4616
4617 let rpc_response: RpcResponse<serde_json::Value> = response
4618 .json()
4619 .await
4620 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4621
4622 if let Some(error) = rpc_response.error {
4623 return Err(AmpError::rpc(format!(
4624 "RPC error importing blinding key: {} (code: {})",
4625 error.message, error.code
4626 )));
4627 }
4628
4629 tracing::info!(
4630 "Successfully imported blinding key for address: {}",
4631 address
4632 );
4633 Ok(())
4634 }
4635
4636 /// Gets wallet information using getwalletinfo RPC
4637 ///
4638 /// # Arguments
4639 /// * `wallet_name` - Name of the wallet to get information for
4640 ///
4641 /// # Errors
4642 /// Returns an error if the RPC call fails
4643 ///
4644 /// # Examples
4645 /// ```no_run
4646 /// # use amp_rs::ElementsRpc;
4647 /// # #[tokio::main]
4648 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4649 /// let rpc = ElementsRpc::from_env()?;
4650 /// let info = rpc.get_wallet_info("my_wallet").await?;
4651 /// println!("Wallet info: {:?}", info);
4652 /// # Ok(())
4653 /// # }
4654 /// ```
4655 pub async fn get_wallet_info(&self, wallet_name: &str) -> Result<serde_json::Value, AmpError> {
4656 // First load the wallet to ensure it's available
4657 self.load_wallet(wallet_name).await?;
4658
4659 let params = serde_json::json!([]);
4660
4661 // Create RPC request for getwalletinfo
4662 let request = RpcRequest {
4663 jsonrpc: "1.0".to_string(),
4664 id: "amp-client".to_string(),
4665 method: "getwalletinfo".to_string(),
4666 params,
4667 };
4668
4669 // Use the wallet-specific RPC endpoint
4670 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4671
4672 let response = self
4673 .client
4674 .post(&wallet_url)
4675 .basic_auth(&self.username, Some(&self.password))
4676 .json(&request)
4677 .send()
4678 .await
4679 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4680
4681 if !response.status().is_success() {
4682 return Err(AmpError::rpc(format!(
4683 "RPC request failed with status: {}",
4684 response.status()
4685 )));
4686 }
4687
4688 let rpc_response: RpcResponse<serde_json::Value> = response
4689 .json()
4690 .await
4691 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4692
4693 if let Some(error) = rpc_response.error {
4694 return Err(AmpError::rpc(format!(
4695 "RPC error getting wallet info: {} (code: {})",
4696 error.message, error.code
4697 )));
4698 }
4699
4700 if let Some(result) = rpc_response.result {
4701 tracing::info!("Successfully retrieved wallet info for: {}", wallet_name);
4702 return Ok(result);
4703 }
4704
4705 Err(AmpError::rpc(format!(
4706 "Failed to get wallet info for '{wallet_name}': unexpected response format"
4707 )))
4708 }
4709
4710 /// Gets the unconfidential address for a confidential address
4711 ///
4712 /// # Arguments
4713 /// * `wallet_name` - Name of the wallet
4714 /// * `confidential_address` - The confidential address to convert
4715 ///
4716 /// # Errors
4717 /// Returns an error if the RPC call fails
4718 ///
4719 /// # Examples
4720 /// ```no_run
4721 /// # use amp_rs::ElementsRpc;
4722 /// # #[tokio::main]
4723 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4724 /// let rpc = ElementsRpc::from_env()?;
4725 /// let unconf = rpc.get_unconfidential_address("my_wallet", "VTpz...").await?;
4726 /// println!("Unconfidential address: {}", unconf);
4727 /// # Ok(())
4728 /// # }
4729 /// ```
4730 pub async fn get_unconfidential_address(
4731 &self,
4732 wallet_name: &str,
4733 confidential_address: &str,
4734 ) -> Result<String, AmpError> {
4735 // First load the wallet to ensure it's available
4736 self.load_wallet(wallet_name).await?;
4737
4738 let params = serde_json::json!([confidential_address]);
4739
4740 // Create RPC request for getunconfidentialaddress
4741 let request = RpcRequest {
4742 jsonrpc: "1.0".to_string(),
4743 id: "amp-client".to_string(),
4744 method: "getunconfidentialaddress".to_string(),
4745 params,
4746 };
4747
4748 // Use the wallet-specific RPC endpoint
4749 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4750
4751 let response = self
4752 .client
4753 .post(&wallet_url)
4754 .basic_auth(&self.username, Some(&self.password))
4755 .json(&request)
4756 .send()
4757 .await
4758 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4759
4760 if !response.status().is_success() {
4761 return Err(AmpError::rpc(format!(
4762 "RPC request failed with status: {}",
4763 response.status()
4764 )));
4765 }
4766
4767 let rpc_response: RpcResponse<serde_json::Value> = response
4768 .json()
4769 .await
4770 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4771
4772 if let Some(error) = rpc_response.error {
4773 return Err(AmpError::rpc(format!(
4774 "RPC error getting unconfidential address: {} (code: {})",
4775 error.message, error.code
4776 )));
4777 }
4778
4779 if let Some(result) = rpc_response.result {
4780 if let Some(address) = result.as_str() {
4781 tracing::info!(
4782 "Successfully got unconfidential address for: {}",
4783 confidential_address
4784 );
4785 return Ok(address.to_string());
4786 }
4787 }
4788
4789 Err(AmpError::rpc(format!(
4790 "Failed to get unconfidential address for '{confidential_address}': unexpected response format"
4791 )))
4792 }
4793
4794 /// Imports a private key into the wallet using importprivkey RPC
4795 ///
4796 /// # Arguments
4797 /// * `wallet_name` - Name of the wallet to import into
4798 /// * `private_key` - The private key in WIF format
4799 /// * `label` - Optional label for the address
4800 /// * `rescan` - Whether to rescan the blockchain for transactions
4801 ///
4802 /// # Errors
4803 /// Returns an error if the RPC call fails
4804 ///
4805 /// # Examples
4806 /// ```no_run
4807 /// # use amp_rs::ElementsRpc;
4808 /// # #[tokio::main]
4809 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4810 /// let rpc = ElementsRpc::from_env()?;
4811 /// rpc.import_private_key("my_wallet", "cT1...", Some("my_address"), Some(false)).await?;
4812 /// # Ok(())
4813 /// # }
4814 /// ```
4815 pub async fn import_private_key(
4816 &self,
4817 wallet_name: &str,
4818 private_key: &str,
4819 label: Option<&str>,
4820 rescan: Option<bool>,
4821 ) -> Result<(), AmpError> {
4822 // First load the wallet to ensure it's available
4823 self.load_wallet(wallet_name).await?;
4824
4825 let params = serde_json::json!([private_key, label.unwrap_or(""), rescan.unwrap_or(false)]);
4826
4827 // Create RPC request for importprivkey
4828 let request = RpcRequest {
4829 jsonrpc: "1.0".to_string(),
4830 id: "amp-client".to_string(),
4831 method: "importprivkey".to_string(),
4832 params,
4833 };
4834
4835 // Use the wallet-specific RPC endpoint
4836 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4837
4838 let response = self
4839 .client
4840 .post(&wallet_url)
4841 .basic_auth(&self.username, Some(&self.password))
4842 .json(&request)
4843 .send()
4844 .await
4845 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4846
4847 if !response.status().is_success() {
4848 return Err(AmpError::rpc(format!(
4849 "RPC request failed with status: {}",
4850 response.status()
4851 )));
4852 }
4853
4854 let rpc_response: RpcResponse<serde_json::Value> = response
4855 .json()
4856 .await
4857 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4858
4859 if let Some(error) = rpc_response.error {
4860 return Err(AmpError::rpc(format!(
4861 "RPC error importing private key: {} (code: {})",
4862 error.message, error.code
4863 )));
4864 }
4865
4866 tracing::info!("Successfully imported private key");
4867 Ok(())
4868 }
4869
4870 /// Lists all descriptors in a wallet using listdescriptors RPC
4871 ///
4872 /// # Arguments
4873 /// * `wallet_name` - Name of the wallet
4874 /// * `private_keys` - Whether to include private keys in the output
4875 ///
4876 /// # Errors
4877 /// Returns an error if the RPC call fails
4878 ///
4879 /// # Examples
4880 /// ```no_run
4881 /// # use amp_rs::ElementsRpc;
4882 /// # #[tokio::main]
4883 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4884 /// let rpc = ElementsRpc::from_env()?;
4885 /// let descriptors = rpc.list_descriptors("my_wallet", Some(true)).await?;
4886 /// for desc in descriptors {
4887 /// println!("Descriptor: {}", desc);
4888 /// }
4889 /// # Ok(())
4890 /// # }
4891 /// ```
4892 pub async fn list_descriptors(
4893 &self,
4894 wallet_name: &str,
4895 private_keys: Option<bool>,
4896 ) -> Result<Vec<String>, AmpError> {
4897 // First load the wallet to ensure it's available
4898 self.load_wallet(wallet_name).await?;
4899
4900 let params = serde_json::json!([private_keys.unwrap_or(false)]);
4901
4902 // Create RPC request for listdescriptors
4903 let request = RpcRequest {
4904 jsonrpc: "1.0".to_string(),
4905 id: "amp-client".to_string(),
4906 method: "listdescriptors".to_string(),
4907 params,
4908 };
4909
4910 // Use the wallet-specific RPC endpoint
4911 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4912
4913 let response = self
4914 .client
4915 .post(&wallet_url)
4916 .basic_auth(&self.username, Some(&self.password))
4917 .json(&request)
4918 .send()
4919 .await
4920 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4921
4922 if !response.status().is_success() {
4923 return Err(AmpError::rpc(format!(
4924 "RPC request failed with status: {}",
4925 response.status()
4926 )));
4927 }
4928
4929 let rpc_response: RpcResponse<serde_json::Value> = response
4930 .json()
4931 .await
4932 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4933
4934 if let Some(error) = rpc_response.error {
4935 return Err(AmpError::rpc(format!(
4936 "RPC error listing descriptors: {} (code: {})",
4937 error.message, error.code
4938 )));
4939 }
4940
4941 if let Some(result) = rpc_response.result {
4942 // Result has a "descriptors" array with objects containing "desc" field
4943 if let Some(descriptors_array) = result.get("descriptors").and_then(|v| v.as_array()) {
4944 let descriptors: Vec<String> = descriptors_array
4945 .iter()
4946 .filter_map(|d| d.get("desc").and_then(|v| v.as_str()).map(String::from))
4947 .collect();
4948 tracing::info!(
4949 "Successfully retrieved {} descriptors for wallet: {}",
4950 descriptors.len(),
4951 wallet_name
4952 );
4953 return Ok(descriptors);
4954 }
4955 }
4956
4957 Ok(Vec::new())
4958 }
4959
4960 /// Gets all addresses in a wallet by label using getaddressesbylabel RPC
4961 ///
4962 /// # Arguments
4963 /// * `wallet_name` - Name of the wallet
4964 /// * `label` - Label to filter by (empty string for all addresses)
4965 ///
4966 /// # Errors
4967 /// Returns an error if the RPC call fails
4968 ///
4969 /// # Examples
4970 /// ```no_run
4971 /// # use amp_rs::ElementsRpc;
4972 /// # #[tokio::main]
4973 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4974 /// let rpc = ElementsRpc::from_env()?;
4975 /// let addresses = rpc.get_addresses_by_label("my_wallet", "").await?;
4976 /// for addr in addresses {
4977 /// println!("Address: {}", addr);
4978 /// }
4979 /// # Ok(())
4980 /// # }
4981 /// ```
4982 pub async fn get_addresses_by_label(
4983 &self,
4984 wallet_name: &str,
4985 label: &str,
4986 ) -> Result<Vec<String>, AmpError> {
4987 // First load the wallet to ensure it's available
4988 self.load_wallet(wallet_name).await?;
4989
4990 let params = serde_json::json!([label]);
4991
4992 // Create RPC request for getaddressesbylabel
4993 let request = RpcRequest {
4994 jsonrpc: "1.0".to_string(),
4995 id: "amp-client".to_string(),
4996 method: "getaddressesbylabel".to_string(),
4997 params,
4998 };
4999
5000 // Use the wallet-specific RPC endpoint
5001 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
5002
5003 let response = self
5004 .client
5005 .post(&wallet_url)
5006 .basic_auth(&self.username, Some(&self.password))
5007 .json(&request)
5008 .send()
5009 .await
5010 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
5011
5012 if !response.status().is_success() {
5013 return Err(AmpError::rpc(format!(
5014 "RPC request failed with status: {}",
5015 response.status()
5016 )));
5017 }
5018
5019 let rpc_response: RpcResponse<serde_json::Value> = response
5020 .json()
5021 .await
5022 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
5023
5024 if let Some(error) = rpc_response.error {
5025 return Err(AmpError::rpc(format!(
5026 "RPC error getting addresses by label: {} (code: {})",
5027 error.message, error.code
5028 )));
5029 }
5030
5031 if let Some(result) = rpc_response.result {
5032 // Result is an object with addresses as keys
5033 if let Some(obj) = result.as_object() {
5034 let addresses: Vec<String> = obj.keys().cloned().collect();
5035 tracing::info!(
5036 "Successfully retrieved {} addresses for wallet: {}",
5037 addresses.len(),
5038 wallet_name
5039 );
5040 return Ok(addresses);
5041 }
5042 }
5043
5044 Ok(Vec::new())
5045 }
5046
5047 /// Lists addresses that have received transactions using listreceivedbyaddress RPC
5048 ///
5049 /// # Arguments
5050 /// * `wallet_name` - Name of the wallet to list addresses for
5051 /// * `min_conf` - Minimum number of confirmations (0 for unconfirmed)
5052 /// * `include_empty` - Whether to include addresses that haven't received payments
5053 ///
5054 /// # Errors
5055 /// Returns an error if the RPC call fails
5056 ///
5057 /// # Examples
5058 /// ```no_run
5059 /// # use amp_rs::ElementsRpc;
5060 /// # #[tokio::main]
5061 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
5062 /// let rpc = ElementsRpc::from_env()?;
5063 /// let addresses = rpc.list_received_by_address("my_wallet", 0, true).await?;
5064 /// for addr in addresses {
5065 /// println!("Address: {:?}", addr);
5066 /// }
5067 /// # Ok(())
5068 /// # }
5069 /// ```
5070 pub async fn list_received_by_address(
5071 &self,
5072 wallet_name: &str,
5073 min_conf: u32,
5074 include_empty: bool,
5075 ) -> Result<Vec<ReceivedByAddress>, AmpError> {
5076 // First load the wallet to ensure it's available
5077 self.load_wallet(wallet_name).await?;
5078
5079 let params = serde_json::json!([min_conf, include_empty]);
5080
5081 // Create RPC request for listreceivedbyaddress
5082 let request = RpcRequest {
5083 jsonrpc: "1.0".to_string(),
5084 id: "amp-client".to_string(),
5085 method: "listreceivedbyaddress".to_string(),
5086 params,
5087 };
5088
5089 // Use the wallet-specific RPC endpoint
5090 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
5091
5092 let response = self
5093 .client
5094 .post(&wallet_url)
5095 .basic_auth(&self.username, Some(&self.password))
5096 .json(&request)
5097 .send()
5098 .await
5099 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
5100
5101 if !response.status().is_success() {
5102 return Err(AmpError::rpc(format!(
5103 "RPC request failed with status: {}",
5104 response.status()
5105 )));
5106 }
5107
5108 let rpc_response: RpcResponse<Vec<ReceivedByAddress>> = response
5109 .json()
5110 .await
5111 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
5112
5113 if let Some(error) = rpc_response.error {
5114 return Err(AmpError::rpc(format!(
5115 "RPC error listing received by address: {} (code: {})",
5116 error.message, error.code
5117 )));
5118 }
5119
5120 if let Some(result) = rpc_response.result {
5121 tracing::info!(
5122 "Successfully listed {} addresses for wallet: {}",
5123 result.len(),
5124 wallet_name
5125 );
5126 return Ok(result);
5127 }
5128
5129 Ok(Vec::new())
5130 }
5131}
5132
5133#[cfg(test)]
5134mod elements_rpc_tests {
5135 use super::*;
5136 use httpmock::prelude::*;
5137 use serial_test::serial;
5138 use std::collections::HashMap;
5139
5140 #[test]
5141 fn test_elements_rpc_new() {
5142 let rpc = ElementsRpc::new(
5143 "http://localhost:18884".to_string(),
5144 "user".to_string(),
5145 "pass".to_string(),
5146 );
5147
5148 assert_eq!(rpc.base_url, "http://localhost:18884");
5149 assert_eq!(rpc.username, "user");
5150 assert_eq!(rpc.password, "pass");
5151 }
5152
5153 #[test]
5154 #[serial]
5155 fn test_elements_rpc_from_env_missing_vars() {
5156 // Store original values to restore later
5157 let original_url = env::var("ELEMENTS_RPC_URL").ok();
5158 let original_user = env::var("ELEMENTS_RPC_USER").ok();
5159 let original_password = env::var("ELEMENTS_RPC_PASSWORD").ok();
5160
5161 // Clear environment variables to test error handling
5162 env::remove_var("ELEMENTS_RPC_URL");
5163 env::remove_var("ELEMENTS_RPC_USER");
5164 env::remove_var("ELEMENTS_RPC_PASSWORD");
5165
5166 let result = ElementsRpc::from_env();
5167 assert!(
5168 result.is_err(),
5169 "ElementsRpc::from_env() should fail when env vars are missing"
5170 );
5171
5172 match result.unwrap_err() {
5173 AmpError::Validation(msg) => {
5174 assert!(
5175 msg.contains("ELEMENTS_RPC_URL"),
5176 "Error message should mention missing ELEMENTS_RPC_URL"
5177 );
5178 }
5179 _ => panic!("Expected validation error"),
5180 }
5181
5182 // Restore original values or keep removed if they weren't set
5183 if let Some(val) = original_url {
5184 env::set_var("ELEMENTS_RPC_URL", val);
5185 }
5186 if let Some(val) = original_user {
5187 env::set_var("ELEMENTS_RPC_USER", val);
5188 }
5189 if let Some(val) = original_password {
5190 env::set_var("ELEMENTS_RPC_PASSWORD", val);
5191 }
5192 }
5193
5194 #[test]
5195 #[serial]
5196 fn test_elements_rpc_from_env_success() {
5197 // Store original values to restore later
5198 let original_url = env::var("ELEMENTS_RPC_URL").ok();
5199 let original_user = env::var("ELEMENTS_RPC_USER").ok();
5200 let original_password = env::var("ELEMENTS_RPC_PASSWORD").ok();
5201
5202 // Set test values
5203 env::set_var("ELEMENTS_RPC_URL", "http://localhost:18884");
5204 env::set_var("ELEMENTS_RPC_USER", "testuser");
5205 env::set_var("ELEMENTS_RPC_PASSWORD", "testpass");
5206
5207 let result = ElementsRpc::from_env();
5208 assert!(
5209 result.is_ok(),
5210 "ElementsRpc::from_env() should succeed when all env vars are set"
5211 );
5212
5213 let rpc = result.unwrap();
5214 assert_eq!(rpc.base_url, "http://localhost:18884");
5215 assert_eq!(rpc.username, "testuser");
5216 assert_eq!(rpc.password, "testpass");
5217
5218 // Restore original values or remove if they weren't set
5219 match original_url {
5220 Some(val) => env::set_var("ELEMENTS_RPC_URL", val),
5221 None => env::remove_var("ELEMENTS_RPC_URL"),
5222 }
5223 match original_user {
5224 Some(val) => env::set_var("ELEMENTS_RPC_USER", val),
5225 None => env::remove_var("ELEMENTS_RPC_USER"),
5226 }
5227 match original_password {
5228 Some(val) => env::set_var("ELEMENTS_RPC_PASSWORD", val),
5229 None => env::remove_var("ELEMENTS_RPC_PASSWORD"),
5230 }
5231 }
5232
5233 #[test]
5234 fn test_elements_rpc_method_signatures() {
5235 // Test that all new methods have correct signatures and can be called
5236 let rpc = ElementsRpc::new(
5237 "http://localhost:18884".to_string(),
5238 "user".to_string(),
5239 "pass".to_string(),
5240 );
5241
5242 // Test that methods exist and have correct signatures (compilation test)
5243 let _: std::pin::Pin<
5244 Box<dyn std::future::Future<Output = Result<Vec<Unspent>, AmpError>> + Send + '_>,
5245 > = Box::pin(rpc.list_unspent(Some("test_asset")));
5246
5247 let inputs = vec![TxInput {
5248 txid: "test_txid".to_string(),
5249 vout: 0,
5250 sequence: None,
5251 }];
5252 let outputs = std::collections::HashMap::new();
5253 let assets = std::collections::HashMap::new();
5254
5255 let _: std::pin::Pin<
5256 Box<dyn std::future::Future<Output = Result<String, AmpError>> + Send + '_>,
5257 > = Box::pin(rpc.create_raw_transaction(inputs, outputs, assets));
5258
5259 let _: std::pin::Pin<
5260 Box<dyn std::future::Future<Output = Result<String, AmpError>> + Send + '_>,
5261 > = Box::pin(rpc.send_raw_transaction("test_hex"));
5262
5263 let _: std::pin::Pin<
5264 Box<dyn std::future::Future<Output = Result<TransactionDetail, AmpError>> + Send + '_>,
5265 > = Box::pin(rpc.get_transaction("test_txid"));
5266 }
5267
5268 // Mock RPC response tests for UTXO and transaction operations
5269
5270 #[tokio::test]
5271 async fn test_get_network_info_success() {
5272 let server = MockServer::start();
5273
5274 let mock_response = serde_json::json!({
5275 "jsonrpc": "1.0",
5276 "id": "amp-client",
5277 "result": {
5278 "version": 220000,
5279 "subversion": "/Liquid:22.0.0/",
5280 "protocolversion": 70016,
5281 "localservices": "0000000000000409",
5282 "localrelay": true,
5283 "timeoffset": 0,
5284 "networkactive": true,
5285 "connections": 8,
5286 "networks": [],
5287 "relayfee": 0.00001000,
5288 "incrementalfee": 0.00001000,
5289 "localaddresses": [],
5290 "warnings": ""
5291 }
5292 });
5293
5294 let mock = server.mock(|when, then| {
5295 when.method(POST)
5296 .path("/")
5297 .header("authorization", "Basic dXNlcjpwYXNz") // base64 of "user:pass"
5298 .json_body(serde_json::json!({
5299 "jsonrpc": "1.0",
5300 "id": "amp-client",
5301 "method": "getnetworkinfo",
5302 "params": []
5303 }));
5304 then.status(200)
5305 .header("content-type", "application/json")
5306 .json_body(mock_response);
5307 });
5308
5309 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5310 let result = rpc.get_network_info().await;
5311
5312 assert!(result.is_ok());
5313 let network_info = result.unwrap();
5314 assert_eq!(network_info.version, 220000);
5315 assert_eq!(network_info.subversion, "/Liquid:22.0.0/");
5316 assert_eq!(network_info.connections, 8);
5317
5318 mock.assert();
5319 }
5320
5321 #[tokio::test]
5322 async fn test_get_blockchain_info_success() {
5323 let server = MockServer::start();
5324
5325 let mock_response = serde_json::json!({
5326 "jsonrpc": "1.0",
5327 "id": "amp-client",
5328 "result": {
5329 "chain": "liquidregtest",
5330 "blocks": 12345,
5331 "headers": 12345,
5332 "bestblockhash": "abc123def456789",
5333 "difficulty": 4.656542373906925e-10,
5334 "mediantime": 1640995200,
5335 "verificationprogress": 1.0,
5336 "initialblockdownload": false,
5337 "chainwork": "0000000000000000000000000000000000000000000000000000000000003039",
5338 "size_on_disk": 1234567,
5339 "pruned": false,
5340 "softforks": {},
5341 "warnings": ""
5342 }
5343 });
5344
5345 let mock = server.mock(|when, then| {
5346 when.method(POST)
5347 .path("/")
5348 .header("authorization", "Basic dXNlcjpwYXNz")
5349 .json_body(serde_json::json!({
5350 "jsonrpc": "1.0",
5351 "id": "amp-client",
5352 "method": "getblockchaininfo",
5353 "params": []
5354 }));
5355 then.status(200)
5356 .header("content-type", "application/json")
5357 .json_body(mock_response);
5358 });
5359
5360 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5361 let result = rpc.get_blockchain_info().await;
5362
5363 assert!(result.is_ok());
5364 let blockchain_info = result.unwrap();
5365 assert_eq!(blockchain_info.chain, "liquidregtest");
5366 assert_eq!(blockchain_info.blocks, 12345);
5367 assert_eq!(blockchain_info.bestblockhash, "abc123def456789");
5368
5369 mock.assert();
5370 }
5371
5372 #[tokio::test]
5373 async fn test_list_unspent_with_asset_filter() {
5374 let server = MockServer::start();
5375
5376 let mock_response = serde_json::json!({
5377 "jsonrpc": "1.0",
5378 "id": "amp-client",
5379 "result": [
5380 {
5381 "txid": "abc123def456789",
5382 "vout": 0,
5383 "amount": 100.0,
5384 "asset": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d",
5385 "address": "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
5386 "spendable": true,
5387 "confirmations": 6,
5388 "scriptpubkey": "76a914abc123def456789abc123def456789abc123de88ac"
5389 },
5390 {
5391 "txid": "def456abc123789",
5392 "vout": 1,
5393 "amount": 50.0,
5394 "asset": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d",
5395 "address": "lq1qq3xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
5396 "spendable": true,
5397 "confirmations": 3
5398 }
5399 ]
5400 });
5401
5402 let asset_id = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d";
5403
5404 let mock = server.mock(|when, then| {
5405 when.method(POST)
5406 .path("/")
5407 .header("authorization", "Basic dXNlcjpwYXNz")
5408 .json_body(serde_json::json!({
5409 "jsonrpc": "1.0",
5410 "id": "amp-client",
5411 "method": "listunspent",
5412 "params": [1, 9999999, [], true, {"asset": asset_id}]
5413 }));
5414 then.status(200)
5415 .header("content-type", "application/json")
5416 .json_body(mock_response);
5417 });
5418
5419 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5420 let result = rpc.list_unspent(Some(asset_id)).await;
5421
5422 assert!(result.is_ok());
5423 let utxos = result.unwrap();
5424 assert_eq!(utxos.len(), 2);
5425 assert_eq!(utxos[0].txid, "abc123def456789");
5426 assert_eq!(utxos[0].amount, 100.0);
5427 assert_eq!(utxos[0].asset, asset_id);
5428 assert_eq!(utxos[1].txid, "def456abc123789");
5429 assert_eq!(utxos[1].amount, 50.0);
5430
5431 mock.assert();
5432 }
5433
5434 #[tokio::test]
5435 async fn test_list_unspent_without_filter() {
5436 let server = MockServer::start();
5437
5438 let mock_response = serde_json::json!({
5439 "jsonrpc": "1.0",
5440 "id": "amp-client",
5441 "result": [
5442 {
5443 "txid": "ghi789jkl012345",
5444 "vout": 0,
5445 "amount": 25.0,
5446 "asset": "different_asset_id",
5447 "address": "lq1qq4xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
5448 "spendable": true,
5449 "confirmations": 10
5450 }
5451 ]
5452 });
5453
5454 let mock = server.mock(|when, then| {
5455 when.method(POST)
5456 .path("/")
5457 .header("authorization", "Basic dXNlcjpwYXNz")
5458 .json_body(serde_json::json!({
5459 "jsonrpc": "1.0",
5460 "id": "amp-client",
5461 "method": "listunspent",
5462 "params": [1, 9999999, [], true]
5463 }));
5464 then.status(200)
5465 .header("content-type", "application/json")
5466 .json_body(mock_response);
5467 });
5468
5469 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5470 let result = rpc.list_unspent(None).await;
5471
5472 assert!(result.is_ok());
5473 let utxos = result.unwrap();
5474 assert_eq!(utxos.len(), 1);
5475 assert_eq!(utxos[0].txid, "ghi789jkl012345");
5476 assert_eq!(utxos[0].amount, 25.0);
5477
5478 mock.assert();
5479 }
5480
5481 #[tokio::test]
5482 async fn test_create_raw_transaction_success() {
5483 let server = MockServer::start();
5484
5485 let mock_response = serde_json::json!({
5486 "jsonrpc": "1.0",
5487 "id": "amp-client",
5488 "result": "0200000000010abc123def456789abc123def456789abc123def456789abc123def456789abc123def456789000000006b483045022100..."
5489 });
5490
5491 let mock = server.mock(|when, then| {
5492 when.method(POST)
5493 .path("/")
5494 .header("authorization", "Basic dXNlcjpwYXNz")
5495 .json_body(serde_json::json!({
5496 "jsonrpc": "1.0",
5497 "id": "amp-client",
5498 "method": "createrawtransaction",
5499 "params": [
5500 [
5501 {
5502 "txid": "input_txid_123",
5503 "vout": 0,
5504 "sequence": 4294967295u32
5505 }
5506 ],
5507 {
5508 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq": 100.0
5509 },
5510 0,
5511 false,
5512 {
5513 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d"
5514 }
5515 ]
5516 }));
5517 then.status(200)
5518 .header("content-type", "application/json")
5519 .json_body(mock_response);
5520 });
5521
5522 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5523
5524 let inputs = vec![TxInput {
5525 txid: "input_txid_123".to_string(),
5526 vout: 0,
5527 sequence: Some(0xffffffff),
5528 }];
5529
5530 let mut outputs = HashMap::new();
5531 outputs.insert(
5532 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
5533 100.0,
5534 );
5535
5536 let mut assets = HashMap::new();
5537 assets.insert(
5538 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
5539 "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d".to_string(),
5540 );
5541
5542 let result = rpc.create_raw_transaction(inputs, outputs, assets).await;
5543
5544 assert!(result.is_ok());
5545 let raw_tx = result.unwrap();
5546 assert!(raw_tx.starts_with("0200000000010abc123def456789"));
5547
5548 mock.assert();
5549 }
5550
5551 #[tokio::test]
5552 async fn test_send_raw_transaction_success() {
5553 let server = MockServer::start();
5554
5555 let mock_response = serde_json::json!({
5556 "jsonrpc": "1.0",
5557 "id": "amp-client",
5558 "result": "abc123def456789abc123def456789abc123def456789abc123def456789abc123de"
5559 });
5560
5561 let signed_tx_hex = "0200000000010abc123def456789abc123def456789abc123def456789abc123def456789abc123def456789000000006b483045022100...";
5562
5563 let mock = server.mock(|when, then| {
5564 when.method(POST)
5565 .path("/")
5566 .header("authorization", "Basic dXNlcjpwYXNz")
5567 .json_body(serde_json::json!({
5568 "jsonrpc": "1.0",
5569 "id": "amp-client",
5570 "method": "sendrawtransaction",
5571 "params": [signed_tx_hex]
5572 }));
5573 then.status(200)
5574 .header("content-type", "application/json")
5575 .json_body(mock_response);
5576 });
5577
5578 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5579 let result = rpc.send_raw_transaction(signed_tx_hex).await;
5580
5581 assert!(result.is_ok());
5582 let txid = result.unwrap();
5583 assert_eq!(
5584 txid,
5585 "abc123def456789abc123def456789abc123def456789abc123def456789abc123de"
5586 );
5587
5588 mock.assert();
5589 }
5590
5591 #[tokio::test]
5592 async fn test_get_transaction_success() {
5593 let server = MockServer::start();
5594
5595 let mock_response = serde_json::json!({
5596 "jsonrpc": "1.0",
5597 "id": "amp-client",
5598 "result": {
5599 "txid": "abc123def456789abc123def456789abc123def456789abc123def456789abc123de",
5600 "confirmations": 6,
5601 "blockheight": 12345,
5602 "hex": "0200000000010abc123def456789...",
5603 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
5604 "blocktime": 1640995200,
5605 "time": 1640995200,
5606 "timereceived": 1640995180
5607 }
5608 });
5609
5610 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
5611
5612 let mock = server.mock(|when, then| {
5613 when.method(POST)
5614 .path("/")
5615 .header("authorization", "Basic dXNlcjpwYXNz")
5616 .json_body(serde_json::json!({
5617 "jsonrpc": "1.0",
5618 "id": "amp-client",
5619 "method": "gettransaction",
5620 "params": [txid, true]
5621 }));
5622 then.status(200)
5623 .header("content-type", "application/json")
5624 .json_body(mock_response);
5625 });
5626
5627 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5628 let result = rpc.get_transaction(txid).await;
5629
5630 assert!(result.is_ok());
5631 let tx_detail = result.unwrap();
5632 assert_eq!(tx_detail.txid, txid);
5633 assert_eq!(tx_detail.confirmations, 6);
5634 assert_eq!(tx_detail.blockheight, Some(12345));
5635 assert_eq!(tx_detail.blocktime, Some(1640995200));
5636
5637 mock.assert();
5638 }
5639
5640 // Error handling tests
5641
5642 #[tokio::test]
5643 async fn test_rpc_call_network_failure() {
5644 // Use an invalid URL to simulate network failure
5645 let rpc = ElementsRpc::new(
5646 "http://invalid-host:99999".to_string(),
5647 "user".to_string(),
5648 "pass".to_string(),
5649 );
5650
5651 let result = rpc.get_network_info().await;
5652 assert!(result.is_err());
5653
5654 match result.unwrap_err() {
5655 AmpError::Rpc(msg) => {
5656 assert!(msg.contains("Failed to send RPC request"));
5657 }
5658 _ => panic!("Expected RPC error for network failure"),
5659 }
5660 }
5661
5662 #[tokio::test]
5663 async fn test_rpc_call_http_error_status() {
5664 let server = MockServer::start();
5665
5666 let mock = server.mock(|when, then| {
5667 when.method(POST).path("/");
5668 then.status(500)
5669 .header("content-type", "application/json")
5670 .body("Internal Server Error");
5671 });
5672
5673 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5674 let result = rpc.get_network_info().await;
5675
5676 assert!(result.is_err());
5677 match result.unwrap_err() {
5678 AmpError::Rpc(msg) => {
5679 assert!(msg.contains("RPC request failed with status: 500"));
5680 }
5681 _ => panic!("Expected RPC error for HTTP error status"),
5682 }
5683
5684 mock.assert();
5685 }
5686
5687 #[tokio::test]
5688 async fn test_rpc_call_invalid_json_response() {
5689 let server = MockServer::start();
5690
5691 let mock = server.mock(|when, then| {
5692 when.method(POST).path("/");
5693 then.status(200)
5694 .header("content-type", "application/json")
5695 .body("invalid json response");
5696 });
5697
5698 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5699 let result = rpc.get_network_info().await;
5700
5701 assert!(result.is_err());
5702 match result.unwrap_err() {
5703 AmpError::Rpc(msg) => {
5704 assert!(msg.contains("Failed to parse RPC response"));
5705 }
5706 _ => panic!("Expected RPC error for invalid JSON"),
5707 }
5708
5709 mock.assert();
5710 }
5711
5712 #[tokio::test]
5713 async fn test_rpc_call_error_response() {
5714 let server = MockServer::start();
5715
5716 let mock_response = serde_json::json!({
5717 "jsonrpc": "1.0",
5718 "id": "amp-client",
5719 "result": null,
5720 "error": {
5721 "code": -32601,
5722 "message": "Method not found"
5723 }
5724 });
5725
5726 let mock = server.mock(|when, then| {
5727 when.method(POST).path("/");
5728 then.status(200)
5729 .header("content-type", "application/json")
5730 .json_body(mock_response);
5731 });
5732
5733 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5734 let result = rpc.get_network_info().await;
5735
5736 assert!(result.is_err());
5737 match result.unwrap_err() {
5738 AmpError::Rpc(msg) => {
5739 assert!(msg.contains("RPC error -32601: Method not found"));
5740 }
5741 _ => panic!("Expected RPC error for error response"),
5742 }
5743
5744 mock.assert();
5745 }
5746
5747 #[tokio::test]
5748 async fn test_rpc_call_missing_result() {
5749 let server = MockServer::start();
5750
5751 let mock_response = serde_json::json!({
5752 "jsonrpc": "1.0",
5753 "id": "amp-client",
5754 "result": null,
5755 "error": null
5756 });
5757
5758 let mock = server.mock(|when, then| {
5759 when.method(POST).path("/");
5760 then.status(200)
5761 .header("content-type", "application/json")
5762 .json_body(mock_response);
5763 });
5764
5765 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5766 let result = rpc.get_network_info().await;
5767
5768 assert!(result.is_err());
5769 match result.unwrap_err() {
5770 AmpError::Rpc(msg) => {
5771 assert!(msg.contains("RPC response missing result field"));
5772 }
5773 _ => panic!("Expected RPC error for missing result"),
5774 }
5775
5776 mock.assert();
5777 }
5778
5779 // Authentication tests
5780
5781 #[tokio::test]
5782 async fn test_rpc_authentication_headers() {
5783 let server = MockServer::start();
5784
5785 let mock_response = serde_json::json!({
5786 "jsonrpc": "1.0",
5787 "id": "amp-client",
5788 "result": {
5789 "version": 220000,
5790 "subversion": "/Liquid:22.0.0/",
5791 "protocolversion": 70016,
5792 "localservices": "0000000000000409",
5793 "localrelay": true,
5794 "timeoffset": 0,
5795 "networkactive": true,
5796 "connections": 8,
5797 "networks": [],
5798 "relayfee": 0.00001000,
5799 "incrementalfee": 0.00001000,
5800 "localaddresses": [],
5801 "warnings": ""
5802 }
5803 });
5804
5805 // Test with custom username and password
5806 let mock = server.mock(|when, then| {
5807 when.method(POST)
5808 .path("/")
5809 .header("authorization", "Basic dGVzdHVzZXI6dGVzdHBhc3M=") // base64 of "testuser:testpass"
5810 .json_body(serde_json::json!({
5811 "jsonrpc": "1.0",
5812 "id": "amp-client",
5813 "method": "getnetworkinfo",
5814 "params": []
5815 }));
5816 then.status(200)
5817 .header("content-type", "application/json")
5818 .json_body(mock_response);
5819 });
5820
5821 let rpc = ElementsRpc::new(
5822 server.url("/"),
5823 "testuser".to_string(),
5824 "testpass".to_string(),
5825 );
5826 let result = rpc.get_network_info().await;
5827
5828 assert!(result.is_ok());
5829 mock.assert();
5830 }
5831
5832 // Wallet passphrase tests
5833
5834 #[tokio::test]
5835 async fn test_wallet_passphrase_success() {
5836 let server = MockServer::start();
5837
5838 let mock_response = serde_json::json!({
5839 "jsonrpc": "1.0",
5840 "id": "amp-client",
5841 "result": null
5842 });
5843
5844 let mock = server.mock(|when, then| {
5845 when.method(POST)
5846 .path("/")
5847 .header("authorization", "Basic dXNlcjpwYXNz")
5848 .json_body(serde_json::json!({
5849 "jsonrpc": "1.0",
5850 "id": "amp-client",
5851 "method": "walletpassphrase",
5852 "params": ["my_passphrase", 300]
5853 }));
5854 then.status(200)
5855 .header("content-type", "application/json")
5856 .json_body(mock_response);
5857 });
5858
5859 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5860 let result = rpc.wallet_passphrase("my_passphrase", 300).await;
5861
5862 assert!(result.is_ok());
5863 mock.assert();
5864 }
5865
5866 // Connection validation tests
5867
5868 #[tokio::test]
5869 async fn test_validate_connection_success() {
5870 let server = MockServer::start();
5871
5872 let mock_response = serde_json::json!({
5873 "jsonrpc": "1.0",
5874 "id": "amp-client",
5875 "result": {
5876 "version": 220000,
5877 "subversion": "/Liquid:22.0.0/",
5878 "protocolversion": 70016,
5879 "localservices": "0000000000000409",
5880 "localrelay": true,
5881 "timeoffset": 0,
5882 "networkactive": true,
5883 "connections": 8,
5884 "networks": [],
5885 "relayfee": 0.00001000,
5886 "incrementalfee": 0.00001000,
5887 "localaddresses": [],
5888 "warnings": ""
5889 }
5890 });
5891
5892 let mock = server.mock(|when, then| {
5893 when.method(POST).path("/");
5894 then.status(200)
5895 .header("content-type", "application/json")
5896 .json_body(mock_response);
5897 });
5898
5899 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5900 let result = rpc.validate_connection().await;
5901
5902 assert!(result.is_ok());
5903 mock.assert();
5904 }
5905
5906 #[tokio::test]
5907 async fn test_get_node_status_success() {
5908 let server = MockServer::start();
5909
5910 let network_mock_response = serde_json::json!({
5911 "jsonrpc": "1.0",
5912 "id": "amp-client",
5913 "result": {
5914 "version": 220000,
5915 "subversion": "/Liquid:22.0.0/",
5916 "protocolversion": 70016,
5917 "localservices": "0000000000000409",
5918 "localrelay": true,
5919 "timeoffset": 0,
5920 "networkactive": true,
5921 "connections": 8,
5922 "networks": [],
5923 "relayfee": 0.00001000,
5924 "incrementalfee": 0.00001000,
5925 "localaddresses": [],
5926 "warnings": ""
5927 }
5928 });
5929
5930 let blockchain_mock_response = serde_json::json!({
5931 "jsonrpc": "1.0",
5932 "id": "amp-client",
5933 "result": {
5934 "chain": "liquidregtest",
5935 "blocks": 12345,
5936 "headers": 12345,
5937 "bestblockhash": "abc123def456789",
5938 "difficulty": 4.656542373906925e-10,
5939 "mediantime": 1640995200,
5940 "verificationprogress": 1.0,
5941 "initialblockdownload": false,
5942 "chainwork": "0000000000000000000000000000000000000000000000000000000000003039",
5943 "size_on_disk": 1234567,
5944 "pruned": false,
5945 "softforks": {},
5946 "warnings": ""
5947 }
5948 });
5949
5950 let network_mock = server.mock(|when, then| {
5951 when.method(POST).path("/").json_body(serde_json::json!({
5952 "jsonrpc": "1.0",
5953 "id": "amp-client",
5954 "method": "getnetworkinfo",
5955 "params": []
5956 }));
5957 then.status(200)
5958 .header("content-type", "application/json")
5959 .json_body(network_mock_response);
5960 });
5961
5962 let blockchain_mock = server.mock(|when, then| {
5963 when.method(POST).path("/").json_body(serde_json::json!({
5964 "jsonrpc": "1.0",
5965 "id": "amp-client",
5966 "method": "getblockchaininfo",
5967 "params": []
5968 }));
5969 then.status(200)
5970 .header("content-type", "application/json")
5971 .json_body(blockchain_mock_response);
5972 });
5973
5974 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5975 let result = rpc.get_node_status().await;
5976
5977 assert!(result.is_ok());
5978 let (network_info, blockchain_info) = result.unwrap();
5979 assert_eq!(network_info.version, 220000);
5980 assert_eq!(blockchain_info.blocks, 12345);
5981
5982 network_mock.assert();
5983 blockchain_mock.assert();
5984 }
5985
5986 // Tests for UTXO selection and transaction building logic
5987
5988 #[tokio::test]
5989 async fn test_build_distribution_transaction_zero_amount() {
5990 let rpc = ElementsRpc::new(
5991 "http://localhost:18884".to_string(),
5992 "user".to_string(),
5993 "pass".to_string(),
5994 );
5995
5996 let address_amounts = HashMap::new(); // Empty distribution
5997
5998 let result = rpc
5999 .build_distribution_transaction(
6000 "test_wallet",
6001 "asset_id",
6002 address_amounts,
6003 "change_address",
6004 1.0,
6005 )
6006 .await;
6007
6008 assert!(result.is_err());
6009 match result.unwrap_err() {
6010 AmpError::Validation(msg) => {
6011 assert!(msg.contains("Total distribution amount must be greater than zero"));
6012 }
6013 _ => panic!("Expected validation error for zero distribution amount"),
6014 }
6015 }
6016
6017 #[tokio::test]
6018 async fn test_sign_transaction_validation() {
6019 let rpc = ElementsRpc::new(
6020 "http://localhost:18884".to_string(),
6021 "user".to_string(),
6022 "pass".to_string(),
6023 );
6024
6025 // Mock signer for testing
6026 struct MockSigner {
6027 should_fail: bool,
6028 return_value: String,
6029 }
6030
6031 #[async_trait::async_trait]
6032 impl crate::signer::Signer for MockSigner {
6033 async fn sign_transaction(
6034 &self,
6035 _unsigned_tx: &str,
6036 ) -> Result<String, crate::signer::SignerError> {
6037 if self.should_fail {
6038 Err(crate::signer::SignerError::Lwk(
6039 "Mock signing failure".to_string(),
6040 ))
6041 } else {
6042 // Return a longer hex string to simulate signed transaction (20+ bytes when decoded)
6043 Ok(format!(
6044 "{}deadbeefcafebabe1234567890abcdef",
6045 self.return_value
6046 ))
6047 }
6048 }
6049
6050 fn as_any(&self) -> &dyn std::any::Any {
6051 self
6052 }
6053 }
6054
6055 // Test empty transaction hex
6056 let mock_signer = MockSigner {
6057 should_fail: false,
6058 return_value: "".to_string(),
6059 };
6060 let result = rpc.sign_transaction("", &mock_signer).await;
6061 assert!(result.is_err());
6062 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
6063
6064 // Test odd length hex
6065 let result = rpc.sign_transaction("abc", &mock_signer).await;
6066 assert!(result.is_err());
6067 assert!(result.unwrap_err().to_string().contains("even length"));
6068
6069 // Test invalid hex characters
6070 let result = rpc.sign_transaction("abcg", &mock_signer).await;
6071 assert!(result.is_err());
6072 assert!(result
6073 .unwrap_err()
6074 .to_string()
6075 .contains("invalid hex characters"));
6076
6077 // Test signer failure
6078 let mock_signer = MockSigner {
6079 should_fail: true,
6080 return_value: "".to_string(),
6081 };
6082 let result = rpc.sign_transaction("abcd", &mock_signer).await;
6083 assert!(result.is_err());
6084 assert!(result
6085 .unwrap_err()
6086 .to_string()
6087 .contains("Mock signing failure"));
6088
6089 // Test successful signing
6090 let mock_signer = MockSigner {
6091 should_fail: false,
6092 return_value: "abcd".to_string(),
6093 };
6094 let result = rpc.sign_transaction("abcd", &mock_signer).await;
6095 if result.is_err() {
6096 println!("Error: {}", result.as_ref().unwrap_err());
6097 }
6098 assert!(result.is_ok());
6099 assert_eq!(result.unwrap(), "abcddeadbeefcafebabe1234567890abcdef");
6100 }
6101
6102 #[tokio::test]
6103 async fn test_sign_transaction_validation_edge_cases() {
6104 let rpc = ElementsRpc::new(
6105 "http://localhost:18884".to_string(),
6106 "user".to_string(),
6107 "pass".to_string(),
6108 );
6109
6110 // Mock signer that returns invalid responses
6111 struct BadMockSigner {
6112 return_empty: bool,
6113 return_odd_length: bool,
6114 return_invalid_hex: bool,
6115 return_shorter: bool,
6116 }
6117
6118 #[async_trait::async_trait]
6119 impl crate::signer::Signer for BadMockSigner {
6120 async fn sign_transaction(
6121 &self,
6122 unsigned_tx: &str,
6123 ) -> Result<String, crate::signer::SignerError> {
6124 if self.return_empty {
6125 Ok("".to_string())
6126 } else if self.return_odd_length {
6127 Ok("abc".to_string())
6128 } else if self.return_invalid_hex {
6129 Ok("abcg".to_string())
6130 } else if self.return_shorter {
6131 Ok("ab".to_string()) // Shorter than input "abcd"
6132 } else {
6133 Ok(format!("{}deadbeef", unsigned_tx))
6134 }
6135 }
6136
6137 fn as_any(&self) -> &dyn std::any::Any {
6138 self
6139 }
6140 }
6141
6142 // Test signer returning empty string
6143 let bad_signer = BadMockSigner {
6144 return_empty: true,
6145 return_odd_length: false,
6146 return_invalid_hex: false,
6147 return_shorter: false,
6148 };
6149 let result = rpc.sign_transaction("abcd", &bad_signer).await;
6150 assert!(result.is_err());
6151 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
6152
6153 // Test signer returning odd length hex
6154 let bad_signer = BadMockSigner {
6155 return_empty: false,
6156 return_odd_length: true,
6157 return_invalid_hex: false,
6158 return_shorter: false,
6159 };
6160 let result = rpc.sign_transaction("abcd", &bad_signer).await;
6161 assert!(result.is_err());
6162 assert!(result.unwrap_err().to_string().contains("even length"));
6163
6164 // Test signer returning invalid hex
6165 let bad_signer = BadMockSigner {
6166 return_empty: false,
6167 return_odd_length: false,
6168 return_invalid_hex: true,
6169 return_shorter: false,
6170 };
6171 let result = rpc.sign_transaction("abcd", &bad_signer).await;
6172 assert!(result.is_err());
6173 assert!(result
6174 .unwrap_err()
6175 .to_string()
6176 .contains("invalid hex characters"));
6177
6178 // Test signer returning shorter transaction (invalid)
6179 let bad_signer = BadMockSigner {
6180 return_empty: false,
6181 return_odd_length: false,
6182 return_invalid_hex: false,
6183 return_shorter: true,
6184 };
6185 let result = rpc.sign_transaction("abcd", &bad_signer).await;
6186 assert!(result.is_err());
6187 assert!(result
6188 .unwrap_err()
6189 .to_string()
6190 .contains("shorter than unsigned transaction"));
6191 }
6192
6193 #[tokio::test]
6194 async fn test_sign_transaction_minimum_size_validation() {
6195 let rpc = ElementsRpc::new(
6196 "http://localhost:18884".to_string(),
6197 "user".to_string(),
6198 "pass".to_string(),
6199 );
6200
6201 // Mock signer that returns very small transactions
6202 struct TinyMockSigner;
6203
6204 #[async_trait::async_trait]
6205 impl crate::signer::Signer for TinyMockSigner {
6206 async fn sign_transaction(
6207 &self,
6208 _unsigned_tx: &str,
6209 ) -> Result<String, crate::signer::SignerError> {
6210 Ok("abcd".to_string()) // Only 2 bytes when decoded
6211 }
6212
6213 fn as_any(&self) -> &dyn std::any::Any {
6214 self
6215 }
6216 }
6217
6218 let tiny_signer = TinyMockSigner;
6219 let result = rpc.sign_transaction("abcd", &tiny_signer).await;
6220 assert!(result.is_err());
6221 let error_msg = result.unwrap_err().to_string();
6222 assert!(error_msg.contains("minimum size"));
6223 assert!(error_msg.contains("minimum is 10 bytes"));
6224 }
6225
6226 #[tokio::test]
6227 async fn test_sign_transaction_success_case() {
6228 let rpc = ElementsRpc::new(
6229 "http://localhost:18884".to_string(),
6230 "user".to_string(),
6231 "pass".to_string(),
6232 );
6233
6234 // Mock signer that returns a valid signed transaction
6235 struct GoodMockSigner;
6236
6237 #[async_trait::async_trait]
6238 impl crate::signer::Signer for GoodMockSigner {
6239 async fn sign_transaction(
6240 &self,
6241 unsigned_tx: &str,
6242 ) -> Result<String, crate::signer::SignerError> {
6243 // Return a longer valid hex string (20+ bytes when decoded)
6244 Ok(format!("{}deadbeefcafebabe1234567890abcdef", unsigned_tx))
6245 }
6246
6247 fn as_any(&self) -> &dyn std::any::Any {
6248 self
6249 }
6250 }
6251
6252 let good_signer = GoodMockSigner;
6253
6254 // Test with a reasonable sized unsigned transaction
6255 let unsigned_tx = "0200000000010123456789abcdef"; // 14 bytes when decoded
6256 let result = rpc.sign_transaction(unsigned_tx, &good_signer).await;
6257
6258 assert!(result.is_ok());
6259 let signed_tx = result.unwrap();
6260 assert!(signed_tx.starts_with(unsigned_tx));
6261 assert!(signed_tx.len() > unsigned_tx.len());
6262 assert!(signed_tx.contains("deadbeefcafebabe"));
6263 }
6264
6265 #[tokio::test]
6266 async fn test_sign_and_broadcast_transaction_mock() {
6267 // Create a mock server for testing the broadcast part
6268 let server = MockServer::start();
6269
6270 // Mock the RPC response for sendrawtransaction
6271 let mock = server.mock(|when, then| {
6272 when.method(POST).path("/").json_body(serde_json::json!({
6273 "jsonrpc": "1.0",
6274 "id": "amp-client",
6275 "method": "sendrawtransaction",
6276 "params": ["0200000000010123456789abcdefdeadbeefcafebabe1234567890abcdef"]
6277 }));
6278 then.status(200).json_body(serde_json::json!({
6279 "jsonrpc": "1.0",
6280 "id": "amp-client",
6281 "result": "abc123def456789",
6282 "error": null
6283 }));
6284 });
6285
6286 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6287
6288 // Mock signer for testing
6289 struct TestMockSigner;
6290
6291 #[async_trait::async_trait]
6292 impl crate::signer::Signer for TestMockSigner {
6293 async fn sign_transaction(
6294 &self,
6295 unsigned_tx: &str,
6296 ) -> Result<String, crate::signer::SignerError> {
6297 Ok(format!("{}deadbeefcafebabe1234567890abcdef", unsigned_tx))
6298 }
6299
6300 fn as_any(&self) -> &dyn std::any::Any {
6301 self
6302 }
6303 }
6304
6305 let signer = TestMockSigner;
6306 let unsigned_tx = "0200000000010123456789abcdef";
6307
6308 let result = rpc
6309 .sign_and_broadcast_transaction(unsigned_tx, &signer)
6310 .await;
6311
6312 assert!(result.is_ok());
6313 assert_eq!(result.unwrap(), "abc123def456789");
6314
6315 // Verify the mock was called
6316 mock.assert();
6317 }
6318
6319 #[tokio::test]
6320 async fn test_sign_and_broadcast_transaction_signing_failure() {
6321 let rpc = ElementsRpc::new(
6322 "http://localhost:18884".to_string(),
6323 "user".to_string(),
6324 "pass".to_string(),
6325 );
6326
6327 // Mock signer that fails
6328 struct FailingSigner;
6329
6330 #[async_trait::async_trait]
6331 impl crate::signer::Signer for FailingSigner {
6332 async fn sign_transaction(
6333 &self,
6334 _unsigned_tx: &str,
6335 ) -> Result<String, crate::signer::SignerError> {
6336 Err(crate::signer::SignerError::Lwk(
6337 "Signing failed".to_string(),
6338 ))
6339 }
6340
6341 fn as_any(&self) -> &dyn std::any::Any {
6342 self
6343 }
6344 }
6345
6346 let failing_signer = FailingSigner;
6347 let result = rpc
6348 .sign_and_broadcast_transaction("abcd", &failing_signer)
6349 .await;
6350
6351 assert!(result.is_err());
6352 let error_msg = result.unwrap_err().to_string();
6353 // The error should be a Signer error containing the original failure message
6354 assert!(error_msg.contains("Signer error"));
6355 assert!(error_msg.contains("Signing failed"));
6356 }
6357
6358 #[tokio::test]
6359 async fn test_sign_and_broadcast_transaction_broadcast_failure() {
6360 // Create a mock server that returns an error for broadcast
6361 let server = MockServer::start();
6362
6363 let mock = server.mock(|when, then| {
6364 when.method(POST).path("/");
6365 then.status(200).json_body(serde_json::json!({
6366 "jsonrpc": "1.0",
6367 "id": "amp-client",
6368 "result": null,
6369 "error": {
6370 "code": -26,
6371 "message": "Transaction rejected"
6372 }
6373 }));
6374 });
6375
6376 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6377
6378 // Mock signer that succeeds
6379 struct WorkingSigner;
6380
6381 #[async_trait::async_trait]
6382 impl crate::signer::Signer for WorkingSigner {
6383 async fn sign_transaction(
6384 &self,
6385 unsigned_tx: &str,
6386 ) -> Result<String, crate::signer::SignerError> {
6387 Ok(format!("{}deadbeefcafebabe1234567890abcdef", unsigned_tx))
6388 }
6389
6390 fn as_any(&self) -> &dyn std::any::Any {
6391 self
6392 }
6393 }
6394
6395 let working_signer = WorkingSigner;
6396 let unsigned_tx = "0200000000010123456789abcdef";
6397
6398 let result = rpc
6399 .sign_and_broadcast_transaction(unsigned_tx, &working_signer)
6400 .await;
6401
6402 assert!(result.is_err());
6403 let error_msg = result.unwrap_err().to_string();
6404 assert!(error_msg.contains("Failed during transaction broadcast phase"));
6405 assert!(error_msg.contains("Transaction rejected"));
6406
6407 mock.assert();
6408 }
6409
6410 #[tokio::test]
6411 async fn test_wait_for_confirmations_success() {
6412 let server = MockServer::start();
6413
6414 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
6415
6416 // First call returns 1 confirmation (not enough)
6417 let _mock_response_1 = serde_json::json!({
6418 "jsonrpc": "1.0",
6419 "id": "amp-client",
6420 "result": {
6421 "txid": txid,
6422 "confirmations": 1,
6423 "blockheight": 12345,
6424 "hex": "0200000000010abc123def456789...",
6425 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
6426 "blocktime": 1640995200,
6427 "time": 1640995200,
6428 "timereceived": 1640995180
6429 }
6430 });
6431
6432 // Second call returns 2 confirmations (sufficient)
6433 let mock_response_2 = serde_json::json!({
6434 "jsonrpc": "1.0",
6435 "id": "amp-client",
6436 "result": {
6437 "txid": txid,
6438 "confirmations": 2,
6439 "blockheight": 12345,
6440 "hex": "0200000000010abc123def456789...",
6441 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
6442 "blocktime": 1640995200,
6443 "time": 1640995200,
6444 "timereceived": 1640995180
6445 }
6446 });
6447
6448 // Create a mock that returns 2 confirmations immediately (simpler test)
6449 let mock = server.mock(|when, then| {
6450 when.method(POST)
6451 .path("/")
6452 .header("authorization", "Basic dXNlcjpwYXNz")
6453 .json_body(serde_json::json!({
6454 "jsonrpc": "1.0",
6455 "id": "amp-client",
6456 "method": "gettransaction",
6457 "params": [txid, true]
6458 }));
6459 then.status(200)
6460 .header("content-type", "application/json")
6461 .json_body(mock_response_2); // Return sufficient confirmations immediately
6462 });
6463
6464 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6465
6466 // Use fast polling (1 second) for testing
6467 let result = rpc
6468 .wait_for_confirmations_with_interval(txid, Some(2), Some(1), Some(1))
6469 .await;
6470
6471 assert!(result.is_ok());
6472 let tx_detail = result.unwrap();
6473 assert_eq!(tx_detail.confirmations, 2);
6474 assert_eq!(tx_detail.txid, txid);
6475
6476 // Mock should have been called once
6477 mock.assert();
6478 }
6479
6480 #[tokio::test]
6481 async fn test_wait_for_confirmations_timeout() {
6482 let server = MockServer::start();
6483
6484 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
6485
6486 // Always return insufficient confirmations
6487 let mock_response = serde_json::json!({
6488 "jsonrpc": "1.0",
6489 "id": "amp-client",
6490 "result": {
6491 "txid": txid,
6492 "confirmations": 1,
6493 "blockheight": 12345,
6494 "hex": "0200000000010abc123def456789...",
6495 "blockhash": null,
6496 "blocktime": null,
6497 "time": null,
6498 "timereceived": null
6499 }
6500 });
6501
6502 let _mock = server.mock(|when, then| {
6503 when.method(POST)
6504 .path("/")
6505 .header("authorization", "Basic dXNlcjpwYXNz")
6506 .json_body(serde_json::json!({
6507 "jsonrpc": "1.0",
6508 "id": "amp-client",
6509 "method": "gettransaction",
6510 "params": [txid, true]
6511 }));
6512 then.status(200)
6513 .header("content-type", "application/json")
6514 .json_body(mock_response);
6515 });
6516
6517 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6518
6519 // Use a very short timeout for testing (0 = 3 seconds) and fast polling (1 second)
6520 let result = rpc
6521 .wait_for_confirmations_with_interval(txid, Some(2), Some(0), Some(1))
6522 .await;
6523
6524 assert!(result.is_err());
6525 match result.unwrap_err() {
6526 AmpError::Timeout(msg) => {
6527 assert!(msg.contains("Timeout waiting for confirmations"));
6528 assert!(msg.contains(txid));
6529 assert!(msg.contains("retry confirmation"));
6530 }
6531 _ => panic!("Expected timeout error"),
6532 }
6533
6534 // Mock will be called multiple times during the timeout period
6535 // We don't assert on the exact number since it depends on timing
6536 }
6537
6538 #[tokio::test]
6539 async fn test_wait_for_confirmations_immediate_success() {
6540 let server = MockServer::start();
6541
6542 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
6543
6544 // Transaction already has sufficient confirmations
6545 let mock_response = serde_json::json!({
6546 "jsonrpc": "1.0",
6547 "id": "amp-client",
6548 "result": {
6549 "txid": txid,
6550 "confirmations": 5,
6551 "blockheight": 12345,
6552 "hex": "0200000000010abc123def456789...",
6553 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
6554 "blocktime": 1640995200,
6555 "time": 1640995200,
6556 "timereceived": 1640995180
6557 }
6558 });
6559
6560 let mock = server.mock(|when, then| {
6561 when.method(POST)
6562 .path("/")
6563 .header("authorization", "Basic dXNlcjpwYXNz")
6564 .json_body(serde_json::json!({
6565 "jsonrpc": "1.0",
6566 "id": "amp-client",
6567 "method": "gettransaction",
6568 "params": [txid, true]
6569 }));
6570 then.status(200)
6571 .header("content-type", "application/json")
6572 .json_body(mock_response);
6573 });
6574
6575 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6576
6577 let result = rpc.wait_for_confirmations(txid, Some(2), Some(10)).await;
6578
6579 assert!(result.is_ok());
6580 let tx_detail = result.unwrap();
6581 assert_eq!(tx_detail.confirmations, 5);
6582 assert_eq!(tx_detail.txid, txid);
6583
6584 // Should only need one call since confirmations are already sufficient
6585 mock.assert();
6586 }
6587}
6588
6589/// Configuration for retry behavior in API requests
6590#[derive(Debug, Clone)]
6591pub struct RetryConfig {
6592 /// Maximum number of retry attempts
6593 pub max_attempts: u32,
6594 /// Base delay in milliseconds for exponential backoff
6595 pub base_delay_ms: u64,
6596 /// Maximum delay in milliseconds to cap exponential backoff
6597 pub max_delay_ms: u64,
6598 /// Request timeout in seconds
6599 pub timeout_seconds: u64,
6600}
6601
6602impl Default for RetryConfig {
6603 fn default() -> Self {
6604 Self {
6605 max_attempts: 3,
6606 base_delay_ms: 1000,
6607 max_delay_ms: 30_000,
6608 timeout_seconds: 10,
6609 }
6610 }
6611}
6612
6613impl RetryConfig {
6614 /// Creates a `RetryConfig` from environment variables with default fallbacks
6615 ///
6616 /// Environment variables:
6617 /// - `API_RETRY_MAX_ATTEMPTS`: Maximum retry attempts (default: 3)
6618 /// - `API_RETRY_BASE_DELAY_MS`: Base delay in milliseconds (default: 1000)
6619 /// - `API_RETRY_MAX_DELAY_MS`: Maximum delay in milliseconds (default: 30000)
6620 /// - `API_REQUEST_TIMEOUT_SECONDS`: Request timeout in seconds (default: 10)
6621 ///
6622 /// # Errors
6623 ///
6624 /// Returns an error if any environment variable contains an invalid value
6625 pub fn from_env() -> Result<Self, Error> {
6626 let max_attempts = match env::var("API_RETRY_MAX_ATTEMPTS") {
6627 Ok(val) => val.parse::<u32>().map_err(|e| {
6628 Error::InvalidRetryConfig(format!("Invalid API_RETRY_MAX_ATTEMPTS: {e}"))
6629 })?,
6630 Err(_) => 3,
6631 };
6632
6633 let base_delay_ms = match env::var("API_RETRY_BASE_DELAY_MS") {
6634 Ok(val) => val.parse::<u64>().map_err(|e| {
6635 Error::InvalidRetryConfig(format!("Invalid API_RETRY_BASE_DELAY_MS: {e}"))
6636 })?,
6637 Err(_) => 1000,
6638 };
6639
6640 let max_delay_ms = match env::var("API_RETRY_MAX_DELAY_MS") {
6641 Ok(val) => val.parse::<u64>().map_err(|e| {
6642 Error::InvalidRetryConfig(format!("Invalid API_RETRY_MAX_DELAY_MS: {e}"))
6643 })?,
6644 Err(_) => 30_000,
6645 };
6646
6647 let timeout_seconds = match env::var("API_REQUEST_TIMEOUT_SECONDS") {
6648 Ok(val) => val.parse::<u64>().map_err(|e| {
6649 Error::InvalidRetryConfig(format!("Invalid API_REQUEST_TIMEOUT_SECONDS: {e}"))
6650 })?,
6651 Err(_) => 10,
6652 };
6653
6654 // Validate configuration
6655 if max_attempts == 0 {
6656 return Err(Error::InvalidRetryConfig(
6657 "max_attempts must be greater than 0".to_string(),
6658 ));
6659 }
6660 if base_delay_ms == 0 {
6661 return Err(Error::InvalidRetryConfig(
6662 "base_delay_ms must be greater than 0".to_string(),
6663 ));
6664 }
6665 if max_delay_ms < base_delay_ms {
6666 return Err(Error::InvalidRetryConfig(
6667 "max_delay_ms must be greater than or equal to base_delay_ms".to_string(),
6668 ));
6669 }
6670 if timeout_seconds == 0 {
6671 return Err(Error::InvalidRetryConfig(
6672 "timeout_seconds must be greater than 0".to_string(),
6673 ));
6674 }
6675
6676 Ok(Self {
6677 max_attempts,
6678 base_delay_ms,
6679 max_delay_ms,
6680 timeout_seconds,
6681 })
6682 }
6683
6684 /// Creates a `RetryConfig` optimized for test environments
6685 ///
6686 /// Uses reduced values for faster test execution:
6687 /// - 2 retry attempts
6688 /// - 500ms base delay
6689 /// - 5000ms max delay
6690 /// - 5 second timeout
6691 #[must_use]
6692 pub const fn for_tests() -> Self {
6693 Self {
6694 max_attempts: 2,
6695 base_delay_ms: 500,
6696 max_delay_ms: 5000,
6697 timeout_seconds: 5,
6698 }
6699 }
6700
6701 /// Sets a custom timeout value
6702 #[must_use]
6703 pub const fn with_timeout(mut self, timeout_seconds: u64) -> Self {
6704 self.timeout_seconds = timeout_seconds;
6705 self
6706 }
6707
6708 /// Sets custom max attempts
6709 #[must_use]
6710 pub const fn with_max_attempts(mut self, max_attempts: u32) -> Self {
6711 self.max_attempts = max_attempts;
6712 self
6713 }
6714
6715 /// Sets custom base delay
6716 #[must_use]
6717 pub const fn with_base_delay_ms(mut self, base_delay_ms: u64) -> Self {
6718 self.base_delay_ms = base_delay_ms;
6719 self
6720 }
6721
6722 /// Sets custom max delay
6723 #[must_use]
6724 pub const fn with_max_delay_ms(mut self, max_delay_ms: u64) -> Self {
6725 self.max_delay_ms = max_delay_ms;
6726 self
6727 }
6728}
6729
6730/// HTTP client with sophisticated retry logic and exponential backoff
6731#[derive(Debug, Clone)]
6732pub struct RetryClient {
6733 client: Client,
6734 config: RetryConfig,
6735}
6736
6737impl RetryClient {
6738 /// Creates a new `RetryClient` with the given configuration
6739 #[must_use]
6740 pub fn new(config: RetryConfig) -> Self {
6741 Self {
6742 client: Client::new(),
6743 config,
6744 }
6745 }
6746
6747 /// Creates a new `RetryClient` with default configuration
6748 #[must_use]
6749 pub fn with_default_config() -> Self {
6750 Self::new(RetryConfig::default())
6751 }
6752
6753 /// Creates a new `RetryClient` with test-optimized configuration
6754 #[must_use]
6755 pub fn for_tests() -> Self {
6756 Self::new(RetryConfig::for_tests())
6757 }
6758
6759 /// Executes an HTTP request with retry logic and exponential backoff
6760 ///
6761 /// # Arguments
6762 /// * `request_builder` - A function that creates the request builder
6763 ///
6764 /// # Returns
6765 /// The response if successful, or an error after all retries are exhausted
6766 ///
6767 /// # Errors
6768 /// Returns `TokenError::Timeout` if the request times out
6769 /// Returns `TokenError::RateLimited` if rate limited and retries are exhausted
6770 /// Returns `TokenError::ObtainFailed` if all retry attempts fail
6771 #[allow(clippy::cognitive_complexity)]
6772 pub async fn execute_with_retry<F>(
6773 &self,
6774 request_builder: F,
6775 ) -> Result<reqwest::Response, TokenError>
6776 where
6777 F: Fn() -> reqwest::RequestBuilder + Send + Sync,
6778 {
6779 let mut last_error = String::new();
6780 let mut attempt = 0;
6781
6782 while attempt < self.config.max_attempts {
6783 attempt += 1;
6784
6785 // Create the request with timeout
6786 let request =
6787 request_builder().timeout(StdDuration::from_secs(self.config.timeout_seconds));
6788
6789 // Execute the request
6790 match request.send().await {
6791 Ok(response) => {
6792 let status = response.status();
6793
6794 // Handle rate limiting (429 Too Many Requests)
6795 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
6796 let retry_after = Self::extract_retry_after(&response).unwrap_or(60);
6797
6798 tracing::warn!(
6799 "Rate limited (429) on attempt {}/{}. Retry after {} seconds",
6800 attempt,
6801 self.config.max_attempts,
6802 retry_after
6803 );
6804
6805 // If this is our last attempt, return the rate limit error
6806 if attempt >= self.config.max_attempts {
6807 return Err(TokenError::rate_limited(retry_after));
6808 }
6809
6810 // Wait for the rate limit period (or our max delay, whichever is smaller)
6811 let delay_ms = std::cmp::min(retry_after * 1000, self.config.max_delay_ms);
6812 sleep(StdDuration::from_millis(delay_ms)).await;
6813 continue;
6814 }
6815
6816 // Handle other client errors (4xx) - these are generally not retryable
6817 if status.is_client_error() && status != reqwest::StatusCode::TOO_MANY_REQUESTS
6818 {
6819 last_error = format!("Client error: {status}");
6820 tracing::error!("Non-retryable client error: {}", status);
6821 break;
6822 }
6823
6824 // Handle server errors (5xx) - these are retryable
6825 if status.is_server_error() {
6826 last_error = format!("Server error: {status}");
6827 tracing::warn!(
6828 "Server error {} on attempt {}/{}",
6829 status,
6830 attempt,
6831 self.config.max_attempts
6832 );
6833
6834 if attempt < self.config.max_attempts {
6835 let delay = self.calculate_backoff_delay(attempt);
6836 sleep(delay).await;
6837 continue;
6838 }
6839 break;
6840 }
6841
6842 // Success case
6843 return Ok(response);
6844 }
6845 Err(e) => {
6846 last_error = e.to_string();
6847
6848 // Check if this is a timeout error
6849 if e.is_timeout() {
6850 tracing::warn!(
6851 "Request timeout on attempt {}/{}",
6852 attempt,
6853 self.config.max_attempts
6854 );
6855
6856 if attempt >= self.config.max_attempts {
6857 return Err(TokenError::timeout(self.config.timeout_seconds));
6858 }
6859 } else {
6860 tracing::warn!(
6861 "Request failed on attempt {}/{}: {}",
6862 attempt,
6863 self.config.max_attempts,
6864 e
6865 );
6866 }
6867
6868 // If we have more attempts, wait and retry
6869 if attempt < self.config.max_attempts {
6870 let delay = self.calculate_backoff_delay(attempt);
6871 sleep(delay).await;
6872 }
6873 }
6874 }
6875 }
6876
6877 // All retries exhausted
6878 Err(TokenError::obtain_failed(attempt, last_error))
6879 }
6880
6881 /// Calculates the delay for exponential backoff with jitter
6882 ///
6883 /// Uses the formula: `min(base_delay * 2^(attempt-1) + jitter, max_delay)`
6884 /// where jitter is a random value between 0 and `base_delay/2`
6885 pub fn calculate_backoff_delay(&self, attempt: u32) -> StdDuration {
6886 use rand::Rng;
6887
6888 let base_delay = self.config.base_delay_ms;
6889 let max_delay = self.config.max_delay_ms;
6890
6891 // Calculate exponential backoff: base_delay * 2^(attempt-1)
6892 let exponential_delay = base_delay * 2_u64.pow(attempt.saturating_sub(1));
6893
6894 // Add jitter (random value between 0 and base_delay/2)
6895 let jitter = rand::thread_rng().gen_range(0..=base_delay / 2);
6896 let total_delay = exponential_delay + jitter;
6897
6898 // Cap at max_delay
6899 let final_delay = std::cmp::min(total_delay, max_delay);
6900
6901 tracing::debug!(
6902 "Calculated backoff delay for attempt {}: {}ms (exponential: {}ms, jitter: {}ms, capped at: {}ms)",
6903 attempt,
6904 final_delay,
6905 exponential_delay,
6906 jitter,
6907 max_delay
6908 );
6909
6910 StdDuration::from_millis(final_delay)
6911 }
6912
6913 /// Extracts the Retry-After header value from a 429 response
6914 ///
6915 /// Returns the number of seconds to wait, or None if the header is not present
6916 /// or cannot be parsed
6917 fn extract_retry_after(response: &reqwest::Response) -> Option<u64> {
6918 response
6919 .headers()
6920 .get("retry-after")
6921 .and_then(|value| value.to_str().ok())
6922 .and_then(|s| s.parse::<u64>().ok())
6923 }
6924
6925 /// Gets the underlying reqwest client
6926 #[must_use]
6927 pub const fn client(&self) -> &Client {
6928 &self.client
6929 }
6930
6931 /// Gets the retry configuration
6932 #[must_use]
6933 pub const fn config(&self) -> &RetryConfig {
6934 &self.config
6935 }
6936}
6937
6938/// Singleton instance of the `TokenManager` for shared token storage across all `ApiClient` instances
6939static GLOBAL_TOKEN_MANAGER: OnceCell<Arc<TokenManager>> = OnceCell::const_new();
6940
6941/// Core token manager with proactive refresh and secure storage
6942#[derive(Debug)]
6943pub struct TokenManager {
6944 pub token_data: Arc<Mutex<Option<TokenData>>>,
6945 pub retry_client: RetryClient,
6946 base_url: Url,
6947 /// Semaphore to ensure only one token operation (obtain/refresh) happens at a time
6948 /// This prevents race conditions where multiple threads try to refresh/obtain simultaneously
6949 token_operation_semaphore: Arc<Semaphore>,
6950}
6951
6952impl TokenManager {
6953 /// Gets the global singleton instance of `TokenManager`
6954 ///
6955 /// This ensures all `ApiClient` instances share the same token storage,
6956 /// preventing multiple token acquisition attempts in concurrent tests.
6957 ///
6958 /// # Errors
6959 /// Returns an error if the `TokenManager` cannot be initialized
6960 pub async fn get_global_instance() -> Result<Arc<Self>, Error> {
6961 let manager = GLOBAL_TOKEN_MANAGER
6962 .get_or_try_init(|| async {
6963 let config = RetryConfig::from_env()?;
6964 let base_url = get_amp_api_base_url()?;
6965 let manager = Self::with_config_and_base_url(config, base_url).await?;
6966 Ok::<Arc<Self>, Error>(Arc::new(manager))
6967 })
6968 .await?;
6969
6970 Ok(manager.clone())
6971 }
6972
6973 /// Creates a new `TokenManager` with default configuration
6974 ///
6975 /// # Errors
6976 /// Returns an error if the base URL cannot be obtained from environment variables
6977 pub async fn new() -> Result<Self, Error> {
6978 let config = RetryConfig::from_env()?;
6979 Self::with_config(config).await
6980 }
6981
6982 /// Creates a new `TokenManager` with the specified retry configuration
6983 ///
6984 /// # Errors
6985 /// Returns an error if the base URL cannot be obtained from environment variables
6986 pub async fn with_config(config: RetryConfig) -> Result<Self, Error> {
6987 let base_url = get_amp_api_base_url()?;
6988 Self::with_config_and_base_url(config, base_url).await
6989 }
6990
6991 /// Creates a new `TokenManager` with the specified configuration and base URL (for testing)
6992 ///
6993 /// # Errors
6994 /// This method is infallible but returns Result for API consistency
6995 pub async fn with_config_and_base_url(
6996 config: RetryConfig,
6997 base_url: Url,
6998 ) -> Result<Self, Error> {
6999 let manager = Self {
7000 token_data: Arc::new(Mutex::new(None)),
7001 retry_client: RetryClient::new(config),
7002 base_url,
7003 token_operation_semaphore: Arc::new(Semaphore::new(1)),
7004 };
7005
7006 // Load token from disk if persistence is enabled
7007 if Self::should_persist_tokens() {
7008 if let Ok(Some(token_data)) = manager.load_token_from_disk().await {
7009 *manager.token_data.lock().await = Some(token_data);
7010 tracing::info!("Token loaded from disk during initialization");
7011 }
7012 }
7013
7014 Ok(manager)
7015 }
7016
7017 /// Creates a new `TokenManager` with a pre-set mock token (for testing)
7018 ///
7019 /// # Errors
7020 /// This method is infallible but returns Result for API consistency
7021 pub fn with_mock_token(
7022 config: RetryConfig,
7023 base_url: Url,
7024 mock_token: String,
7025 ) -> Result<Self, Error> {
7026 let expires_at = Utc::now() + Duration::hours(24); // Mock token valid for 24 hours
7027 let token_data = TokenData::new(mock_token, expires_at);
7028
7029 let manager = Self {
7030 token_data: Arc::new(Mutex::new(Some(token_data))),
7031 retry_client: RetryClient::new(config),
7032 base_url,
7033 token_operation_semaphore: Arc::new(Semaphore::new(1)),
7034 };
7035
7036 Ok(manager)
7037 }
7038
7039 /// Gets a valid authentication token with proactive refresh logic
7040 ///
7041 /// This method implements thread-safe token management logic:
7042 /// 1. Check if a valid token exists and is not expiring soon (within 5 minutes)
7043 /// 2. If token needs refresh/obtain, acquire semaphore to prevent concurrent operations
7044 /// 3. Double-check token state after acquiring semaphore (another thread may have updated it)
7045 /// 4. Perform atomic token update operations
7046 /// 5. Return the valid token
7047 ///
7048 /// # Thread Safety
7049 /// This method is fully thread-safe and prevents race conditions by:
7050 /// - Using a semaphore to ensure only one token operation at a time
7051 /// - Double-checking token state after acquiring the semaphore
7052 /// - Performing atomic token updates within the critical section
7053 ///
7054 /// # Errors
7055 /// Returns a `TokenError` if token acquisition or refresh fails after all retries
7056 pub async fn get_token(&self) -> Result<String, Error> {
7057 // Fast path: check if we have a valid token without acquiring semaphore
7058 if let Some(token) = self.check_existing_token().await? {
7059 return Ok(token);
7060 }
7061
7062 // Slow path: token needs refresh/obtain, acquire semaphore for thread safety
7063 let _permit = self.acquire_token_semaphore().await?;
7064
7065 // Double-check token state after acquiring semaphore - another thread may have updated it
7066 if let Some(token) = self.check_existing_token().await? {
7067 tracing::debug!("Token was updated by another thread, using existing valid token");
7068 return Ok(token);
7069 }
7070
7071 // At this point, we need to refresh or obtain a new token
7072 self.handle_token_refresh_or_obtain().await
7073 }
7074
7075 /// Checks if we have a valid existing token that doesn't expire soon
7076 async fn check_existing_token(&self) -> Result<Option<String>, Error> {
7077 let token_guard = self.token_data.lock().await;
7078 if let Some(ref token_data) = *token_guard {
7079 if !token_data.expires_soon(Duration::minutes(5)) {
7080 tracing::debug!("Using existing valid token");
7081 let token = token_data.token.expose_secret().clone();
7082 drop(token_guard);
7083 return Ok(Some(token));
7084 }
7085 }
7086 drop(token_guard);
7087 Ok(None)
7088 }
7089
7090 /// Acquires the token operation semaphore for thread-safe operations
7091 async fn acquire_token_semaphore(&self) -> Result<tokio::sync::SemaphorePermit<'_>, Error> {
7092 let permit = self
7093 .token_operation_semaphore
7094 .acquire()
7095 .await
7096 .map_err(|e| {
7097 Error::Token(TokenError::storage(format!(
7098 "Failed to acquire token operation semaphore: {e}"
7099 )))
7100 })?;
7101
7102 tracing::debug!("Acquired token operation semaphore for thread-safe token management");
7103 Ok(permit)
7104 }
7105
7106 /// Handles the token refresh or obtain logic
7107 async fn handle_token_refresh_or_obtain(&self) -> Result<String, Error> {
7108 let needs_refresh = self.determine_token_operation().await;
7109
7110 if needs_refresh {
7111 match self.refresh_token_internal().await {
7112 Ok(token) => {
7113 tracing::info!("Token refreshed successfully");
7114 return Ok(token);
7115 }
7116 Err(e) => {
7117 tracing::warn!("Token refresh failed, falling back to obtain: {e}");
7118 // Fall through to obtain new token
7119 }
7120 }
7121 }
7122
7123 // Either we needed to obtain from the start, or refresh failed
7124 self.obtain_token_internal().await
7125 }
7126
7127 /// Determines whether we need to refresh or obtain a new token
7128 async fn determine_token_operation(&self) -> bool {
7129 let token_guard = self.token_data.lock().await;
7130 token_guard.as_ref().map_or_else(
7131 || {
7132 tracing::info!("No token exists, will obtain new token");
7133 false
7134 },
7135 |token_data| {
7136 if token_data.is_expired() {
7137 tracing::info!("Token is expired, will obtain new token");
7138 false
7139 } else {
7140 tracing::info!("Token expires soon, will attempt refresh");
7141 true
7142 }
7143 },
7144 )
7145 }
7146
7147 /// Obtains a new authentication token using environment credentials with retry logic
7148 ///
7149 /// This method:
7150 /// 1. Reads credentials from environment variables
7151 /// 2. Makes a token request with retry logic
7152 /// 3. Stores the new token with 24-hour expiry
7153 /// 4. Returns the token string
7154 ///
7155 /// # Thread Safety
7156 /// This method acquires the token operation semaphore to ensure thread-safe operation.
7157 /// For internal use within already-synchronized contexts, use `obtain_token_internal()`.
7158 ///
7159 /// # Errors
7160 /// Returns an error if:
7161 /// - Environment variables are missing
7162 /// - All retry attempts fail
7163 /// - Response parsing fails
7164 pub async fn obtain_token(&self) -> Result<String, Error> {
7165 let _permit = self
7166 .token_operation_semaphore
7167 .acquire()
7168 .await
7169 .map_err(|e| {
7170 Error::Token(TokenError::storage(format!(
7171 "Failed to acquire token operation semaphore: {e}"
7172 )))
7173 })?;
7174
7175 self.obtain_token_internal().await
7176 }
7177
7178 /// Internal method to obtain a new authentication token without acquiring semaphore
7179 ///
7180 /// This method should only be called from contexts where the token operation semaphore
7181 /// has already been acquired (e.g., from within `get_token()`).
7182 ///
7183 /// # Errors
7184 /// Returns an error if:
7185 /// - Environment variables are missing
7186 /// - All retry attempts fail
7187 /// - Response parsing fails
7188 async fn obtain_token_internal(&self) -> Result<String, Error> {
7189 tracing::debug!("Obtaining new authentication token");
7190
7191 let request_payload = Self::get_credentials_from_env()?;
7192 let url = self.build_obtain_token_url();
7193 let response = self.execute_token_request(&url, &request_payload).await?;
7194 let token_response = self.parse_token_response(response).await?;
7195
7196 self.store_token_data(&token_response.token).await;
7197
7198 tracing::info!("New authentication token obtained successfully");
7199 Ok(token_response.token)
7200 }
7201
7202 /// Gets credentials from environment variables
7203 fn get_credentials_from_env() -> Result<TokenRequest, Error> {
7204 let username = env::var("AMP_USERNAME")
7205 .map_err(|_| Error::MissingEnvVar("AMP_USERNAME".to_string()))?;
7206 let password = env::var("AMP_PASSWORD")
7207 .map_err(|_| Error::MissingEnvVar("AMP_PASSWORD".to_string()))?;
7208
7209 Ok(TokenRequest { username, password })
7210 }
7211
7212 /// Builds the URL for token obtain endpoint
7213 fn build_obtain_token_url(&self) -> Url {
7214 let mut url = self.base_url.clone();
7215 url.path_segments_mut()
7216 .unwrap()
7217 .push("user")
7218 .push("obtain_token");
7219 url
7220 }
7221
7222 /// Executes the token request with retry logic
7223 async fn execute_token_request(
7224 &self,
7225 url: &Url,
7226 request_payload: &TokenRequest,
7227 ) -> Result<reqwest::Response, Error> {
7228 let response = self
7229 .retry_client
7230 .execute_with_retry(|| {
7231 self.retry_client
7232 .client()
7233 .post(url.clone())
7234 .json(request_payload)
7235 })
7236 .await
7237 .map_err(Error::Token)?;
7238
7239 if !response.status().is_success() {
7240 let status = response.status();
7241 let error_text = response
7242 .text()
7243 .await
7244 .unwrap_or_else(|_| "Unknown error".to_string());
7245 return Err(Error::TokenRequestFailed { status, error_text });
7246 }
7247
7248 Ok(response)
7249 }
7250
7251 /// Parses the token response from the API
7252 async fn parse_token_response(
7253 &self,
7254 response: reqwest::Response,
7255 ) -> Result<TokenResponse, Error> {
7256 response
7257 .json()
7258 .await
7259 .map_err(|e| Error::ResponseParsingFailed(e.to_string()))
7260 }
7261
7262 /// Stores the token data with 24-hour expiry and optional disk persistence
7263 async fn store_token_data(&self, token: &str) {
7264 let expires_at = Utc::now() + Duration::days(1);
7265 let token_data = TokenData::new(token.to_string(), expires_at);
7266
7267 // Atomic token update - hold the lock for the minimal time needed
7268 *self.token_data.lock().await = Some(token_data.clone());
7269 tracing::debug!("Token data updated atomically in storage");
7270
7271 // Save to disk if persistence is enabled
7272 if Self::should_persist_tokens() {
7273 if let Err(e) = self.save_token_to_disk(&token_data).await {
7274 tracing::warn!("Failed to save token to disk: {e}");
7275 }
7276 }
7277 }
7278
7279 /// Refreshes the current authentication token with fallback to obtain on failure
7280 ///
7281 /// This method:
7282 /// 1. Uses the existing token to request a refresh
7283 /// 2. Updates the stored token data on success
7284 /// 3. Falls back to obtaining a new token if refresh fails
7285 ///
7286 /// # Thread Safety
7287 /// This method acquires the token operation semaphore to ensure thread-safe operation.
7288 /// For internal use within already-synchronized contexts, use `refresh_token_internal()`.
7289 ///
7290 /// # Errors
7291 /// Returns an error if both refresh and obtain operations fail
7292 pub async fn refresh_token(&self) -> Result<String, Error> {
7293 let _permit = self
7294 .token_operation_semaphore
7295 .acquire()
7296 .await
7297 .map_err(|e| {
7298 Error::Token(TokenError::storage(format!(
7299 "Failed to acquire token operation semaphore: {e}"
7300 )))
7301 })?;
7302
7303 self.refresh_token_internal().await
7304 }
7305
7306 /// Internal method to refresh the current authentication token without acquiring semaphore
7307 ///
7308 /// This method should only be called from contexts where the token operation semaphore
7309 /// has already been acquired (e.g., from within `get_token()`).
7310 ///
7311 /// # Errors
7312 /// Returns an error if both refresh and obtain operations fail
7313 #[allow(clippy::cognitive_complexity)]
7314 async fn refresh_token_internal(&self) -> Result<String, Error> {
7315 tracing::debug!("Refreshing authentication token");
7316
7317 let Some(current_token) = self.get_current_token_for_refresh().await else {
7318 tracing::warn!("No token available for refresh, obtaining new token");
7319 return self.obtain_token_internal().await;
7320 };
7321
7322 let url = self.build_refresh_token_url();
7323 let response = self.execute_refresh_request(&url, ¤t_token).await;
7324
7325 match response {
7326 Ok(resp) => self.handle_refresh_response(resp).await,
7327 Err(e) => {
7328 tracing::warn!("Token refresh request failed: {e}, falling back to obtain");
7329 self.obtain_token_internal().await
7330 }
7331 }
7332 }
7333
7334 /// Gets the current token for refresh operations
7335 async fn get_current_token_for_refresh(&self) -> Option<String> {
7336 let token_guard = self.token_data.lock().await;
7337 token_guard
7338 .as_ref()
7339 .map(|token_data| token_data.token.expose_secret().clone())
7340 }
7341
7342 /// Builds the URL for token refresh endpoint
7343 fn build_refresh_token_url(&self) -> Url {
7344 let mut url = self.base_url.clone();
7345 url.path_segments_mut()
7346 .unwrap()
7347 .push("user")
7348 .push("refresh_token");
7349 url
7350 }
7351
7352 /// Executes the refresh request with retry logic
7353 async fn execute_refresh_request(
7354 &self,
7355 url: &Url,
7356 current_token: &str,
7357 ) -> Result<reqwest::Response, TokenError> {
7358 self.retry_client
7359 .execute_with_retry(|| {
7360 self.retry_client
7361 .client()
7362 .post(url.clone())
7363 .header(AUTHORIZATION, format!("token {current_token}"))
7364 })
7365 .await
7366 }
7367
7368 /// Handles the refresh response, either storing the new token or falling back to obtain
7369 async fn handle_refresh_response(&self, resp: reqwest::Response) -> Result<String, Error> {
7370 if !resp.status().is_success() {
7371 let status = resp.status();
7372 let error_text = resp
7373 .text()
7374 .await
7375 .unwrap_or_else(|_| "Unknown error".to_string());
7376
7377 tracing::warn!("Token refresh failed with status {status}: {error_text}");
7378 return self.obtain_token_internal().await;
7379 }
7380
7381 let token_response: TokenResponse = resp
7382 .json()
7383 .await
7384 .map_err(|e| Error::ResponseParsingFailed(e.to_string()))?;
7385
7386 self.store_token_data(&token_response.token).await;
7387 tracing::info!("Authentication token refreshed successfully");
7388 Ok(token_response.token)
7389 }
7390
7391 /// Gets current token information for debugging and monitoring
7392 ///
7393 /// Returns detailed information about the current token including:
7394 /// - Expiry time and remaining duration
7395 /// - Token age since acquisition
7396 /// - Expiry status flags
7397 ///
7398 /// # Returns
7399 /// `Some(TokenInfo)` if a token exists, `None` if no token is stored
7400 ///
7401 /// # Errors
7402 /// Returns an error if token information retrieval fails
7403 pub async fn get_token_info(&self) -> Result<Option<TokenInfo>, Error> {
7404 tracing::debug!("Retrieving token information for debugging");
7405
7406 let token_info = self.token_data.lock().await.as_ref().map(TokenInfo::from);
7407
7408 match &token_info {
7409 Some(info) => {
7410 tracing::debug!(
7411 "Token info retrieved - expires_at: {}, age: {:?}, expires_in: {:?}, is_expired: {}, expires_soon: {}",
7412 info.expires_at,
7413 info.age,
7414 info.expires_in,
7415 info.is_expired,
7416 info.expires_soon
7417 );
7418 }
7419 None => {
7420 tracing::debug!("No token information available - no token stored");
7421 }
7422 }
7423
7424 Ok(token_info)
7425 }
7426
7427 /// Clears the stored token (useful for testing scenarios)
7428 ///
7429 /// This method removes the current token from storage, forcing the next
7430 /// `get_token()` call to obtain a fresh token.
7431 ///
7432 /// # Errors
7433 /// Returns an error if token clearing fails
7434 pub async fn clear_token(&self) -> Result<(), Error> {
7435 tracing::debug!("Clearing stored token from memory and disk");
7436
7437 let had_token = self.clear_token_from_memory().await;
7438 self.clear_token_from_disk_if_enabled().await;
7439 Self::log_token_clear_result(had_token);
7440
7441 Ok(())
7442 }
7443
7444 /// Clears the token from memory and returns whether a token was present
7445 async fn clear_token_from_memory(&self) -> bool {
7446 let mut token_guard = self.token_data.lock().await;
7447 let had_token = token_guard.is_some();
7448 *token_guard = None;
7449 drop(token_guard);
7450 had_token
7451 }
7452
7453 /// Clears the token from disk if persistence is enabled
7454 async fn clear_token_from_disk_if_enabled(&self) {
7455 if Self::should_persist_tokens() {
7456 if let Err(e) = self.remove_token_from_disk().await {
7457 tracing::warn!("Failed to remove token from disk: {e}");
7458 }
7459 }
7460 }
7461
7462 /// Logs the result of the token clearing operation
7463 fn log_token_clear_result(had_token: bool) {
7464 if had_token {
7465 tracing::info!("Token successfully cleared from memory and disk - next get_token() will obtain fresh token");
7466 } else {
7467 tracing::debug!("No token was stored to clear");
7468 }
7469 }
7470
7471 /// Forces a token refresh regardless of current token status
7472 ///
7473 /// This method bypasses the normal proactive refresh logic and immediately
7474 /// attempts to refresh the current token. If no token exists or refresh fails,
7475 /// it falls back to obtaining a new token.
7476 ///
7477 /// # Thread Safety
7478 /// This method is fully thread-safe and uses the same semaphore-based synchronization
7479 /// as other token operations to prevent race conditions.
7480 ///
7481 /// # Errors
7482 /// Returns an error if both refresh and obtain operations fail
7483 pub async fn force_refresh(&self) -> Result<String, Error> {
7484 tracing::info!("Forcing token refresh - bypassing normal proactive refresh logic");
7485
7486 let _permit = self.acquire_token_semaphore().await?;
7487 self.log_token_status_for_refresh().await;
7488 self.execute_forced_refresh().await
7489 }
7490
7491 /// Logs the current token status for forced refresh operation
7492 async fn log_token_status_for_refresh(&self) {
7493 let has_token = {
7494 let token_guard = self.token_data.lock().await;
7495 token_guard.is_some()
7496 };
7497
7498 if has_token {
7499 tracing::debug!("Existing token found, attempting forced refresh");
7500 } else {
7501 tracing::debug!("No existing token found, will obtain new token");
7502 }
7503 }
7504
7505 /// Executes the forced refresh operation
7506 async fn execute_forced_refresh(&self) -> Result<String, Error> {
7507 match self.refresh_token_internal().await {
7508 Ok(token) => {
7509 tracing::info!("Forced token refresh completed successfully");
7510 Ok(token)
7511 }
7512 Err(e) => {
7513 tracing::error!("Forced token refresh failed: {e}");
7514 Err(e)
7515 }
7516 }
7517 }
7518
7519 /// Determines if token persistence is enabled based on environment variables
7520 ///
7521 /// Token persistence is enabled when:
7522 /// - `AMP_TESTS=live` (for live API testing)
7523 /// - `AMP_TOKEN_PERSISTENCE=true` is set
7524 /// - NOT in mock test environments (to prevent test pollution)
7525 fn should_persist_tokens() -> bool {
7526 // Use the new environment detection logic
7527 let environment = TokenEnvironment::detect();
7528
7529 // Never persist tokens in mock environments to prevent test pollution
7530 if environment.is_mock() {
7531 tracing::debug!("Token persistence disabled - mock environment detected");
7532 return false;
7533 }
7534
7535 // Check if explicitly enabled
7536 if env::var("AMP_TOKEN_PERSISTENCE").unwrap_or_default() == "true" {
7537 tracing::debug!("Token persistence enabled - AMP_TOKEN_PERSISTENCE=true");
7538 return true;
7539 }
7540
7541 // Use environment-based persistence setting
7542 let should_persist = environment.should_persist_tokens();
7543 tracing::debug!(
7544 "Token persistence setting from environment: {}",
7545 should_persist
7546 );
7547 should_persist
7548 }
7549
7550 /// Loads token data from disk if it exists and is valid
7551 async fn load_token_from_disk(&self) -> Result<Option<TokenData>, Error> {
7552 let token_file = "token.json";
7553
7554 if !self.token_file_exists(token_file).await {
7555 return Ok(None);
7556 }
7557
7558 let content = self.read_token_file(token_file).await?;
7559 self.parse_and_validate_token(token_file, &content).await
7560 }
7561
7562 /// Checks if the token file exists on disk
7563 async fn token_file_exists(&self, token_file: &str) -> bool {
7564 tokio::fs::try_exists(token_file).await.map_or_else(
7565 |_| {
7566 tracing::debug!("Error checking token file existence: {}", token_file);
7567 false
7568 },
7569 |exists| {
7570 if !exists {
7571 tracing::debug!("Token file does not exist: {}", token_file);
7572 }
7573 exists
7574 },
7575 )
7576 }
7577
7578 /// Reads the token file content from disk
7579 async fn read_token_file(&self, token_file: &str) -> Result<String, Error> {
7580 use tokio::fs;
7581
7582 match fs::read_to_string(token_file).await {
7583 Ok(content) => Ok(content),
7584 Err(e) => {
7585 tracing::warn!("Failed to read token file: {e}");
7586 Err(Error::Token(TokenError::storage(format!(
7587 "Failed to read token file: {e}"
7588 ))))
7589 }
7590 }
7591 }
7592
7593 /// Parses token content and validates expiration
7594 async fn parse_and_validate_token(
7595 &self,
7596 token_file: &str,
7597 content: &str,
7598 ) -> Result<Option<TokenData>, Error> {
7599 match serde_json::from_str::<TokenData>(content) {
7600 Ok(token_data) => self.handle_parsed_token(token_file, token_data).await,
7601 Err(e) => self.handle_parse_error(token_file, e).await,
7602 }
7603 }
7604
7605 /// Handles successfully parsed token data, checking expiration
7606 async fn handle_parsed_token(
7607 &self,
7608 token_file: &str,
7609 token_data: TokenData,
7610 ) -> Result<Option<TokenData>, Error> {
7611 if token_data.is_expired() {
7612 tracing::info!("Token loaded from disk is expired, removing file");
7613 let _ = tokio::fs::remove_file(token_file).await;
7614 Ok(None)
7615 } else {
7616 tracing::info!("Valid token loaded from disk");
7617 Ok(Some(token_data))
7618 }
7619 }
7620
7621 /// Handles token parsing errors by cleaning up the invalid file
7622 async fn handle_parse_error(
7623 &self,
7624 token_file: &str,
7625 e: serde_json::Error,
7626 ) -> Result<Option<TokenData>, Error> {
7627 tracing::warn!("Failed to parse token file, removing: {e}");
7628 let _ = tokio::fs::remove_file(token_file).await;
7629 Err(Error::Token(TokenError::serialization(format!(
7630 "Failed to parse token file: {e}"
7631 ))))
7632 }
7633
7634 /// Saves token data to disk
7635 async fn save_token_to_disk(&self, token_data: &TokenData) -> Result<(), Error> {
7636 use tokio::fs;
7637
7638 let token_file = "token.json";
7639
7640 match serde_json::to_string_pretty(token_data) {
7641 Ok(json) => match fs::write(token_file, json).await {
7642 Ok(()) => {
7643 tracing::debug!("Token saved to disk: {}", token_file);
7644 Ok(())
7645 }
7646 Err(e) => {
7647 tracing::error!("Failed to write token file: {e}");
7648 Err(Error::Token(TokenError::storage(format!(
7649 "Failed to write token file: {e}"
7650 ))))
7651 }
7652 },
7653 Err(e) => {
7654 tracing::error!("Failed to serialize token data: {e}");
7655 Err(Error::Token(TokenError::serialization(format!(
7656 "Failed to serialize token data: {e}"
7657 ))))
7658 }
7659 }
7660 }
7661
7662 /// Removes the token file from disk
7663 async fn remove_token_from_disk(&self) -> Result<(), Error> {
7664 use tokio::fs;
7665
7666 let token_file = "token.json";
7667
7668 match fs::remove_file(token_file).await {
7669 Ok(()) => {
7670 tracing::debug!("Token file removed from disk: {}", token_file);
7671 Ok(())
7672 }
7673 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7674 tracing::debug!("Token file does not exist, nothing to remove");
7675 Ok(())
7676 }
7677 Err(e) => {
7678 tracing::warn!("Failed to remove token file: {e}");
7679 Err(Error::Token(TokenError::storage(format!(
7680 "Failed to remove token file: {e}"
7681 ))))
7682 }
7683 }
7684 }
7685
7686 /// Forces cleanup of token persistence files (useful for testing)
7687 /// This method removes token files regardless of persistence settings
7688 ///
7689 /// # Errors
7690 /// Returns an error if:
7691 /// - File system permissions prevent deletion of the token file
7692 /// - I/O errors occur during file deletion operations
7693 /// - The token file is locked by another process
7694 pub async fn force_cleanup_token_files() -> Result<(), Error> {
7695 use tokio::fs;
7696
7697 let token_file = "token.json";
7698
7699 match fs::remove_file(token_file).await {
7700 Ok(()) => {
7701 tracing::debug!("Token file forcefully removed: {}", token_file);
7702 Ok(())
7703 }
7704 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7705 tracing::debug!("No token file to clean up");
7706 Ok(())
7707 }
7708 Err(e) => {
7709 tracing::warn!("Failed to force cleanup token file: {e}");
7710 Err(Error::Token(TokenError::storage(format!(
7711 "Failed to force cleanup token file: {e}"
7712 ))))
7713 }
7714 }
7715 }
7716
7717 /// Resets the global `TokenManager` singleton (useful for testing)
7718 ///
7719 /// This method clears the global singleton instance, forcing the next
7720 /// call to `get_global_instance()` to create a fresh `TokenManager`.
7721 /// Primarily intended for test scenarios where a clean state is needed.
7722 ///
7723 /// # Errors
7724 /// Returns an error if:
7725 /// - Token clearing operations fail during the reset process
7726 /// - File system errors occur when clearing persistent token data
7727 /// - The global instance is in an invalid state that prevents cleanup
7728 pub async fn reset_global_instance() -> Result<(), Error> {
7729 // Clear any existing token from the current global instance
7730 if let Some(manager) = GLOBAL_TOKEN_MANAGER.get() {
7731 let _ = manager.clear_token().await;
7732 }
7733
7734 // Reset the OnceCell to allow a new instance to be created
7735 // Note: OnceCell doesn't have a reset method, so we can't actually reset it
7736 // The best we can do is clear the token from the existing instance
7737 tracing::debug!("Global TokenManager instance token cleared for testing");
7738 Ok(())
7739 }
7740}
7741
7742#[derive(Debug)]
7743pub struct ApiClient {
7744 client: Client,
7745 base_url: Url,
7746 token_strategy: Box<dyn TokenStrategy>,
7747}
7748
7749#[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
7750impl ApiClient {
7751 /// Creates a new API client with the base URL from environment variables.
7752 ///
7753 /// Automatically selects the appropriate token strategy based on environment detection:
7754 /// - Mock strategy for mock environments (no persistence, isolated tokens)
7755 /// - Live strategy for live environments (full token management with persistence)
7756 ///
7757 /// # Errors
7758 ///
7759 /// Returns an error if:
7760 /// - The `AMP_API_BASE_URL` environment variable contains an invalid URL
7761 /// - Token strategy initialization fails
7762 ///
7763 /// # Examples
7764 /// ```no_run
7765 /// # use amp_rs::ApiClient;
7766 /// # #[tokio::main]
7767 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7768 /// // Create a new client - automatically detects environment
7769 /// let client = ApiClient::new().await?;
7770 ///
7771 /// // Client is ready to use
7772 /// let assets = client.get_assets().await?;
7773 /// println!("Found {} assets", assets.len());
7774 /// # Ok(())
7775 /// # }
7776 /// ```
7777 pub async fn new() -> Result<Self, Error> {
7778 let base_url = get_amp_api_base_url()?;
7779 let client = Client::new();
7780
7781 // Automatic strategy selection based on environment
7782 let token_strategy = TokenEnvironment::create_auto_strategy(None).await?;
7783
7784 tracing::info!(
7785 "Created ApiClient with {} strategy for base URL: {}",
7786 token_strategy.strategy_type(),
7787 base_url
7788 );
7789
7790 Ok(Self {
7791 client,
7792 base_url,
7793 token_strategy,
7794 })
7795 }
7796
7797 /// Creates a new API client with the specified base URL.
7798 ///
7799 /// Automatically selects the appropriate token strategy based on environment detection.
7800 ///
7801 /// # Errors
7802 ///
7803 /// Returns an error if token strategy initialization fails.
7804 ///
7805 /// # Examples
7806 /// ```no_run
7807 /// # use amp_rs::ApiClient;
7808 /// # use reqwest::Url;
7809 /// # #[tokio::main]
7810 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7811 /// let base_url = Url::parse("https://amp-test.blockstream.com/api")?;
7812 /// let client = ApiClient::with_base_url(base_url).await?;
7813 ///
7814 /// // Client is ready to use with the specified URL
7815 /// let assets = client.get_assets().await?;
7816 /// # Ok(())
7817 /// # }
7818 /// ```
7819 pub async fn with_base_url(base_url: Url) -> Result<Self, Error> {
7820 let client = Client::new();
7821
7822 // Automatic strategy selection based on environment
7823 let token_strategy = TokenEnvironment::create_auto_strategy(None).await?;
7824
7825 tracing::info!(
7826 "Created ApiClient with {} strategy for base URL: {}",
7827 token_strategy.strategy_type(),
7828 base_url
7829 );
7830
7831 Ok(Self {
7832 client,
7833 base_url,
7834 token_strategy,
7835 })
7836 }
7837
7838 /// Creates a new API client with a custom token strategy (useful for testing).
7839 ///
7840 /// # Errors
7841 ///
7842 /// Returns an error if the base URL cannot be obtained from environment variables.
7843 pub fn with_token_strategy(token_strategy: Box<dyn TokenStrategy>) -> Result<Self, Error> {
7844 let base_url = get_amp_api_base_url()?;
7845
7846 tracing::info!(
7847 "Created ApiClient with explicit {} strategy for base URL: {}",
7848 token_strategy.strategy_type(),
7849 base_url
7850 );
7851
7852 Ok(Self {
7853 client: Client::new(),
7854 base_url,
7855 token_strategy,
7856 })
7857 }
7858
7859 /// Creates a new API client with a custom token manager (useful for testing).
7860 ///
7861 /// # Errors
7862 ///
7863 /// Returns an error if the base URL cannot be obtained from environment variables.
7864 pub fn with_token_manager(token_manager: Arc<TokenManager>) -> Result<Self, Error> {
7865 let base_url = get_amp_api_base_url()?;
7866 let token_strategy: Box<dyn TokenStrategy> =
7867 Box::new(LiveTokenStrategy::with_token_manager(token_manager));
7868
7869 tracing::info!(
7870 "Created ApiClient with custom token manager for base URL: {}",
7871 base_url
7872 );
7873
7874 Ok(Self {
7875 client: Client::new(),
7876 base_url,
7877 token_strategy,
7878 })
7879 }
7880
7881 /// Creates a new API client for testing with a mock token strategy that always returns a fixed token.
7882 /// This bypasses all token acquisition and management logic and uses complete isolation.
7883 ///
7884 /// # Errors
7885 ///
7886 /// This method is infallible but returns Result for API consistency.
7887 ///
7888 /// # Examples
7889 /// ```
7890 /// # use amp_rs::ApiClient;
7891 /// # use reqwest::Url;
7892 /// # #[tokio::main]
7893 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7894 /// let base_url = Url::parse("http://localhost:8080/api")?;
7895 /// let client = ApiClient::with_mock_token(base_url, "test_token".to_string())?;
7896 ///
7897 /// // Client will always use "test_token" for authentication
7898 /// let token = client.get_token().await?;
7899 /// assert_eq!(token, "test_token");
7900 /// # Ok(())
7901 /// # }
7902 /// ```
7903 pub fn with_mock_token(base_url: Url, mock_token: String) -> Result<Self, Error> {
7904 let client = Client::new();
7905 let token_strategy: Box<dyn TokenStrategy> = Box::new(MockTokenStrategy::new(mock_token));
7906
7907 tracing::info!(
7908 "Created ApiClient with explicit mock token strategy for base URL: {}",
7909 base_url
7910 );
7911
7912 Ok(Self {
7913 client,
7914 base_url,
7915 token_strategy,
7916 })
7917 }
7918
7919 /// Obtains a new authentication token from the AMP API.
7920 ///
7921 /// **Note**: This method is deprecated in favor of the automatic token management
7922 /// provided by `get_token()`. The `TokenManager` handles token acquisition internally
7923 /// with enhanced retry logic and error handling.
7924 ///
7925 /// # Errors
7926 ///
7927 /// Returns an error if:
7928 /// - The `AMP_USERNAME` or `AMP_PASSWORD` environment variables are not set
7929 /// - The HTTP request fails
7930 /// - The token request is rejected by the server
7931 /// - The response cannot be parsed
7932 #[deprecated(note = "Use get_token() instead - it provides automatic token management")]
7933 pub async fn obtain_amp_token(&self) -> Result<String, Error> {
7934 // Delegate to get_token for backward compatibility
7935 self.get_token().await
7936 }
7937
7938 /// Gets current token information for debugging and monitoring.
7939 ///
7940 /// Returns detailed information about the current token including:
7941 /// - Expiry time and remaining duration
7942 /// - Token age since acquisition
7943 /// - Expiry status flags
7944 ///
7945 /// Note: Mock strategies may return limited or no token information.
7946 ///
7947 /// # Returns
7948 /// `Some(TokenInfo)` if a token exists, `None` if no token is stored or strategy doesn't support info
7949 ///
7950 /// # Errors
7951 /// Returns an error if token information retrieval fails
7952 ///
7953 /// # Examples
7954 /// ```no_run
7955 /// # use amp_rs::ApiClient;
7956 /// # #[tokio::main]
7957 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7958 /// let client = ApiClient::new().await?;
7959 ///
7960 /// if let Some(token_info) = client.get_token_info().await? {
7961 /// println!("Token expires at: {}", token_info.expires_at);
7962 /// println!("Token is expired: {}", token_info.is_expired);
7963 /// } else {
7964 /// println!("No token stored or mock strategy in use");
7965 /// }
7966 /// # Ok(())
7967 /// # }
7968 /// ```
7969 pub async fn get_token_info(&self) -> Result<Option<TokenInfo>, Error> {
7970 // Only live strategies support detailed token information
7971 if let Some(live_strategy) = self
7972 .token_strategy
7973 .as_any()
7974 .downcast_ref::<LiveTokenStrategy>()
7975 {
7976 live_strategy.get_token_info().await
7977 } else {
7978 // Mock strategies don't provide detailed token information
7979 tracing::debug!(
7980 "Token info not available for {} strategy",
7981 self.token_strategy.strategy_type()
7982 );
7983 Ok(None)
7984 }
7985 }
7986
7987 /// Clears the stored token (useful for testing scenarios).
7988 ///
7989 /// This method removes the current token from storage, forcing the next
7990 /// `get_token()` call to obtain a fresh token.
7991 ///
7992 /// # Errors
7993 /// Returns an error if token clearing fails
7994 ///
7995 /// # Examples
7996 /// ```no_run
7997 /// # use amp_rs::ApiClient;
7998 /// # #[tokio::main]
7999 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8000 /// let client = ApiClient::new().await?;
8001 ///
8002 /// // Clear any existing token
8003 /// client.clear_token().await?;
8004 ///
8005 /// // Next get_token() call will obtain a fresh token
8006 /// let token = client.get_token().await?;
8007 /// # Ok(())
8008 /// # }
8009 /// ```
8010 pub async fn clear_token(&self) -> Result<(), Error> {
8011 self.token_strategy.clear_token().await
8012 }
8013
8014 /// Forces a token refresh regardless of current token status.
8015 ///
8016 /// This method bypasses the normal proactive refresh logic and immediately
8017 /// attempts to refresh the current token. If no token exists or refresh fails,
8018 /// it falls back to obtaining a new token.
8019 ///
8020 /// # Errors
8021 /// Returns an error if both refresh and obtain operations fail
8022 pub async fn force_refresh(&self) -> Result<String, Error> {
8023 // Clear current token and get a fresh one
8024 self.token_strategy.clear_token().await?;
8025 self.token_strategy.get_token().await
8026 }
8027
8028 /// Resets the global `TokenManager` singleton (useful for testing).
8029 ///
8030 /// This method clears the token from the global `TokenManager` instance.
8031 /// Primarily intended for test scenarios where a clean token state is needed.
8032 ///
8033 /// # Errors
8034 /// Returns an error if the reset operation fails
8035 pub async fn reset_global_token_manager() -> Result<(), Error> {
8036 TokenManager::reset_global_instance().await
8037 }
8038
8039 /// Gets a valid authentication token with automatic token management.
8040 ///
8041 /// This method uses the integrated `TokenManager` to handle:
8042 /// - Proactive token refresh (5 minutes before expiry)
8043 /// - Automatic fallback from refresh to obtain on failure
8044 /// - Retry logic with exponential backoff
8045 /// - Thread-safe token storage
8046 ///
8047 /// # Errors
8048 ///
8049 /// Returns an error if token acquisition or refresh fails after all retries.
8050 ///
8051 /// # Examples
8052 /// ```no_run
8053 /// # use amp_rs::ApiClient;
8054 /// # #[tokio::main]
8055 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8056 /// let client = ApiClient::new().await?;
8057 ///
8058 /// // Get a valid token - automatically handles refresh if needed
8059 /// let token = client.get_token().await?;
8060 /// println!("Got token: {}", &token[..10]); // Print first 10 chars
8061 /// # Ok(())
8062 /// # }
8063 /// ```
8064 pub async fn get_token(&self) -> Result<String, Error> {
8065 self.token_strategy.get_token().await
8066 }
8067
8068 /// Returns the type of token strategy currently in use
8069 ///
8070 /// This is useful for debugging and testing to verify the correct strategy is selected.
8071 ///
8072 /// # Returns
8073 /// A string indicating the strategy type: "mock" or "live"
8074 #[must_use]
8075 pub fn get_strategy_type(&self) -> &'static str {
8076 self.token_strategy.strategy_type()
8077 }
8078
8079 /// Returns whether the current strategy persists tokens
8080 ///
8081 /// This is useful for understanding the token management behavior.
8082 ///
8083 /// # Returns
8084 /// `true` if tokens are persisted to disk, `false` for in-memory only
8085 #[must_use]
8086 pub fn should_persist_tokens(&self) -> bool {
8087 self.token_strategy.should_persist()
8088 }
8089
8090 /// Force cleanup of token files (for test cleanup)
8091 ///
8092 /// This is a static method that can be used to cleanup token files
8093 /// without needing an `ApiClient` instance. Useful for test teardown.
8094 ///
8095 /// # Errors
8096 /// Returns an error if token file cleanup fails
8097 pub async fn force_cleanup_token_files() -> Result<(), Error> {
8098 // Only cleanup if we're not in a live test environment
8099 let environment = TokenEnvironment::detect();
8100 if !environment.is_live() || environment.is_mock() {
8101 TokenManager::force_cleanup_token_files().await?;
8102 tracing::debug!("Token files cleaned up for non-live environment");
8103 } else {
8104 tracing::debug!("Skipping token file cleanup in live environment");
8105 }
8106 Ok(())
8107 }
8108
8109 async fn request_raw(
8110 &self,
8111 method: Method,
8112 path: &[&str],
8113 body: Option<impl serde::Serialize>,
8114 ) -> Result<reqwest::Response, Error> {
8115 let debug_logging = std::env::var("AMP_DEBUG").is_ok();
8116
8117 if debug_logging {
8118 eprintln!("🌐 HTTP Request: {} /{}", method, path.join("/"));
8119 }
8120
8121 let token = self.get_token().await?;
8122 let mut url = self.base_url.clone();
8123 url.path_segments_mut().unwrap().extend(path);
8124
8125 if debug_logging {
8126 eprintln!("🔗 Full URL: {url}");
8127 }
8128
8129 // Retry logic for network issues
8130 let max_retries = 3;
8131 let mut last_error = None;
8132
8133 for attempt in 1..=max_retries {
8134 if debug_logging && attempt > 1 {
8135 eprintln!("🔄 Retry attempt {attempt} of {max_retries}");
8136 }
8137
8138 let mut request_builder = self
8139 .client
8140 .request(method.clone(), url.clone())
8141 .header(AUTHORIZATION, format!("token {token}"))
8142 .timeout(std::time::Duration::from_secs(60)); // Increase timeout to 60 seconds
8143
8144 if let Some(ref body) = body {
8145 if debug_logging && attempt == 1 {
8146 if let Ok(json_body) = serde_json::to_string_pretty(&body) {
8147 eprintln!(
8148 "📤 Request body ({} bytes):\n{}",
8149 json_body.len(),
8150 json_body
8151 );
8152 } else {
8153 eprintln!("📤 Request body: [serialization failed]");
8154 }
8155 }
8156 request_builder = request_builder.json(&body);
8157 } else if debug_logging && attempt == 1 {
8158 eprintln!("📤 Request body: [empty]");
8159 }
8160
8161 if debug_logging {
8162 eprintln!("🚀 Sending HTTP request (attempt {attempt})...");
8163 }
8164
8165 match request_builder.send().await {
8166 Ok(response) => {
8167 let status = response.status();
8168
8169 if debug_logging {
8170 eprintln!("📥 Response status: {status}");
8171 }
8172
8173 if !status.is_success() {
8174 let error_text = response
8175 .text()
8176 .await
8177 .unwrap_or_else(|_| "Unknown error".to_string());
8178
8179 if debug_logging {
8180 eprintln!("❌ Error response body: {error_text}");
8181 }
8182
8183 return Err(Error::RequestFailed(format!(
8184 "Request to {path:?} failed with status {status}: {error_text}"
8185 )));
8186 }
8187
8188 if debug_logging {
8189 eprintln!("✅ HTTP request successful");
8190 }
8191
8192 return Ok(response);
8193 }
8194 Err(e) => {
8195 if debug_logging {
8196 eprintln!("❌ HTTP request failed (attempt {attempt}): {e:?}");
8197 eprintln!(" Error kind: {:?}", e.is_timeout());
8198 eprintln!(" Is connect error: {}", e.is_connect());
8199 eprintln!(" Is request error: {}", e.is_request());
8200 }
8201
8202 last_error = Some(e);
8203
8204 // Only retry on network/connection errors, not on client errors
8205 if attempt < max_retries {
8206 #[allow(clippy::cast_sign_loss)] // attempt is always positive (1-3)
8207 let delay = std::time::Duration::from_millis((attempt as u64) * 1000);
8208 if debug_logging {
8209 eprintln!("⏳ Waiting {}ms before retry...", delay.as_millis());
8210 }
8211 tokio::time::sleep(delay).await;
8212 }
8213 }
8214 }
8215 }
8216
8217 // If we get here, all retries failed
8218 if debug_logging {
8219 eprintln!("❌ All {max_retries} retry attempts failed");
8220 }
8221
8222 Err(Error::Reqwest(last_error.unwrap()))
8223 }
8224
8225 async fn request_json<T: DeserializeOwned>(
8226 &self,
8227 method: Method,
8228 path: &[&str],
8229 body: Option<impl serde::Serialize>,
8230 ) -> Result<T, Error> {
8231 // Capture request context for better error messages
8232 let method_str = method.to_string();
8233 let mut url = self.base_url.clone();
8234 url.path_segments_mut().unwrap().extend(path);
8235 let endpoint = url.to_string();
8236 let expected_type = std::any::type_name::<T>().to_string();
8237
8238 let response = self.request_raw(method, path, body).await?;
8239
8240 // Try to deserialize, capturing raw response on failure
8241 match response.text().await {
8242 Ok(raw_response) => serde_json::from_str(&raw_response).map_err(|e| {
8243 Error::ResponseDeserializationFailed {
8244 method: method_str,
8245 endpoint,
8246 expected_type,
8247 serde_error: e.to_string(),
8248 raw_response,
8249 }
8250 }),
8251 Err(e) => Err(Error::ResponseParsingFailed(format!(
8252 "Failed to read response body: {e}"
8253 ))),
8254 }
8255 }
8256
8257 async fn request_empty(
8258 &self,
8259 method: Method,
8260 path: &[&str],
8261 body: Option<impl serde::Serialize>,
8262 ) -> Result<(), Error> {
8263 self.request_raw(method, path, body).await?;
8264 Ok(())
8265 }
8266
8267 /// Gets the API changelog.
8268 ///
8269 /// # Errors
8270 ///
8271 /// Returns an error if:
8272 /// - Authentication fails
8273 /// - The HTTP request fails
8274 /// - The server returns an error status
8275 /// - The response cannot be parsed as JSON
8276 pub async fn get_changelog(&self) -> Result<serde_json::Value, Error> {
8277 self.request_json(Method::GET, &["changelog"], None::<&()>)
8278 .await
8279 }
8280
8281 /// Changes the user's password.
8282 ///
8283 /// # Errors
8284 ///
8285 /// Returns an error if:
8286 /// - Authentication fails
8287 /// - The HTTP request fails
8288 /// - The server rejects the password change
8289 /// - The response cannot be parsed
8290 pub async fn user_change_password(
8291 &self,
8292 password: Secret<String>,
8293 ) -> Result<ChangePasswordResponse, Error> {
8294 let request = ChangePasswordRequest {
8295 password: Secret::new(Password(password.expose_secret().clone())),
8296 };
8297 self.request_json(Method::POST, &["user", "change_password"], Some(request))
8298 .await
8299 }
8300
8301 /// Gets a list of all assets.
8302 ///
8303 /// # Errors
8304 ///
8305 /// Returns an error if:
8306 /// - Authentication fails
8307 /// - The HTTP request fails
8308 /// - The server returns an error status
8309 /// - The response cannot be parsed
8310 ///
8311 /// # Examples
8312 /// ```no_run
8313 /// # use amp_rs::ApiClient;
8314 /// # #[tokio::main]
8315 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8316 /// let client = ApiClient::new().await?;
8317 ///
8318 /// let assets = client.get_assets().await?;
8319 /// for asset in assets {
8320 /// println!("Asset: {} ({})", asset.name, asset.ticker.unwrap_or_default());
8321 /// }
8322 /// # Ok(())
8323 /// # }
8324 /// ```
8325 pub async fn get_assets(&self) -> Result<Vec<Asset>, Error> {
8326 self.request_json(Method::GET, &["assets"], None::<&()>)
8327 .await
8328 }
8329
8330 /// Gets a specific asset by UUID.
8331 ///
8332 /// # Errors
8333 ///
8334 /// Returns an error if:
8335 /// - Authentication fails
8336 /// - The HTTP request fails
8337 /// - The asset does not exist
8338 /// - The response cannot be parsed
8339 ///
8340 /// # Examples
8341 /// ```no_run
8342 /// # use amp_rs::ApiClient;
8343 /// # #[tokio::main]
8344 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8345 /// let client = ApiClient::new().await?;
8346 ///
8347 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
8348 /// let asset = client.get_asset(asset_uuid).await?;
8349 ///
8350 /// println!("Asset: {} ({})", asset.name, asset.ticker.unwrap_or_default());
8351 /// println!("Registered: {}, Locked: {}", asset.is_registered, asset.is_locked);
8352 /// # Ok(())
8353 /// # }
8354 /// ```
8355 pub async fn get_asset(&self, asset_uuid: &str) -> Result<Asset, Error> {
8356 self.request_json(Method::GET, &["assets", asset_uuid], None::<&()>)
8357 .await
8358 }
8359
8360 /// Issues a new asset.
8361 ///
8362 /// # Errors
8363 ///
8364 /// Returns an error if:
8365 /// - Authentication fails
8366 /// - The HTTP request fails
8367 /// - The issuance request is invalid
8368 /// - The response cannot be parsed
8369 ///
8370 /// # Examples
8371 /// ```no_run
8372 /// # use amp_rs::{ApiClient, model::IssuanceRequest};
8373 /// # #[tokio::main]
8374 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8375 /// let client = ApiClient::new().await?;
8376 ///
8377 /// let issuance_request = IssuanceRequest {
8378 /// name: "My Token".to_string(),
8379 /// amount: 1000000,
8380 /// destination_address: "vjU2i2EM2viGEzSywpStMPkTX9U9QSDsLSN63kJJYVpxKJZuxaph8v5r5Jf11aqnfBVdjSbrvcJ2pw26".to_string(),
8381 /// domain: "example.com".to_string(),
8382 /// ticker: "MYTKN".to_string(),
8383 /// pubkey: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
8384 /// precision: Some(8),
8385 /// is_confidential: Some(true),
8386 /// is_reissuable: Some(false),
8387 /// reissuance_amount: None,
8388 /// reissuance_address: None,
8389 /// transfer_restricted: Some(false),
8390 /// };
8391 ///
8392 /// let response = client.issue_asset(&issuance_request).await?;
8393 /// println!("Issued asset with UUID: {}", response.asset_uuid);
8394 /// # Ok(())
8395 /// # }
8396 /// ```
8397 pub async fn issue_asset(
8398 &self,
8399 issuance_request: &IssuanceRequest,
8400 ) -> Result<IssuanceResponse, Error> {
8401 self.request_json(Method::POST, &["assets", "issue"], Some(issuance_request))
8402 .await
8403 }
8404
8405 /// Edits an existing asset.
8406 ///
8407 /// # Errors
8408 ///
8409 /// Returns an error if:
8410 /// - Authentication fails
8411 /// - The HTTP request fails
8412 /// - The asset does not exist
8413 /// - The edit request is invalid
8414 /// - The response cannot be parsed
8415 pub async fn edit_asset(
8416 &self,
8417 asset_uuid: &str,
8418 edit_asset_request: &EditAssetRequest,
8419 ) -> Result<Asset, Error> {
8420 self.request_json(
8421 Method::PUT,
8422 &["assets", asset_uuid, "edit"],
8423 Some(edit_asset_request),
8424 )
8425 .await
8426 }
8427
8428 /// Registers an asset with the Blockstream Asset Registry.
8429 ///
8430 /// This method publishes an asset to the public registry, making it discoverable
8431 /// and verifiable by other users and applications. The asset must already exist
8432 /// in the AMP system before it can be registered.
8433 ///
8434 /// # Arguments
8435 ///
8436 /// * `asset_uuid` - The unique identifier of the asset to register
8437 ///
8438 /// # Returns
8439 ///
8440 /// Returns a `RegisterAssetResponse` containing:
8441 /// - `success`: Boolean indicating whether the registration was successful
8442 /// - `message`: Optional status message from the API
8443 /// - `asset_id`: The registered asset identifier (hex string)
8444 ///
8445 /// # Errors
8446 ///
8447 /// Returns an error if:
8448 /// - The asset does not exist or cannot be found (404)
8449 /// - Authentication fails or token is invalid (401)
8450 /// - The asset is already registered (returns success with appropriate message)
8451 /// - Network connectivity issues occur
8452 /// - The server returns an error status (5xx)
8453 /// - The response cannot be parsed
8454 ///
8455 /// # Examples
8456 ///
8457 /// ```no_run
8458 /// # use amp_rs::ApiClient;
8459 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
8460 /// let client = ApiClient::from_env().await?;
8461 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
8462 ///
8463 /// let response = client.register_asset(asset_uuid).await?;
8464 /// if response.success {
8465 /// println!("Asset registered successfully!");
8466 /// println!("Asset ID: {}", response.asset_id);
8467 /// if let Some(message) = response.message {
8468 /// println!("Message: {}", message);
8469 /// }
8470 /// }
8471 /// # Ok(())
8472 /// # }
8473 /// ```
8474 pub async fn register_asset(&self, asset_uuid: &str) -> Result<RegisterAssetResponse, Error> {
8475 // Make HTTP request directly to handle both success and error responses
8476 let token = self.get_token().await?;
8477 let mut url = self.base_url.clone();
8478 url.path_segments_mut()
8479 .unwrap()
8480 .extend(&["assets", asset_uuid, "register"]);
8481
8482 let response = self
8483 .client
8484 .request(Method::GET, url)
8485 .header(AUTHORIZATION, format!("token {token}"))
8486 .timeout(std::time::Duration::from_secs(60))
8487 .send()
8488 .await
8489 .map_err(|e| Error::RequestFailed(format!("HTTP request failed: {e}")))?;
8490
8491 let status = response.status();
8492 let response_text = response.text().await.map_err(|e| {
8493 Error::ResponseParsingFailed(format!("Failed to read response body: {e}"))
8494 })?;
8495
8496 // Handle HTTP 200 - success case
8497 if status == reqwest::StatusCode::OK {
8498 // Try to parse as Asset (full registration response)
8499 if let Ok(asset) = serde_json::from_str::<Asset>(&response_text) {
8500 return Ok(RegisterAssetResponse {
8501 success: true,
8502 message: Some("Asset registered successfully".to_string()),
8503 asset_data: Some(asset),
8504 });
8505 }
8506
8507 // If parsing as Asset fails, return success with raw message
8508 return Ok(RegisterAssetResponse {
8509 success: true,
8510 message: Some(response_text),
8511 asset_data: None,
8512 });
8513 }
8514
8515 // Handle error responses
8516 // Try to parse error response as JSON
8517 if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&response_text) {
8518 // Check for "already registered" error
8519 if let Some(error_msg) = error_json.get("Error").and_then(|e| e.as_str()) {
8520 let error_msg_lower = error_msg.to_lowercase();
8521 if error_msg_lower.contains("already registered") {
8522 return Ok(RegisterAssetResponse {
8523 success: true,
8524 message: Some("Asset is already registered".to_string()),
8525 asset_data: None,
8526 });
8527 }
8528
8529 // Other errors - return as error
8530 return Err(Error::RequestFailed(format!(
8531 "Request to [\"assets\", \"{asset_uuid}\", \"register\"] failed with status {status}: {error_msg}"
8532 )));
8533 }
8534 }
8535
8536 // Fallback error for non-JSON or unexpected responses
8537 Err(Error::RequestFailed(format!(
8538 "Request to [\"assets\", \"{asset_uuid}\", \"register\"] failed with status {status}: {response_text}"
8539 )))
8540 }
8541
8542 /// # Errors
8543 /// Returns an error if:
8544 /// - The asset does not exist or cannot be found
8545 /// - Authentication fails or token is invalid
8546 /// - Network connectivity issues occur
8547 /// - The server returns an error status
8548 pub async fn delete_asset(&self, asset_uuid: &str) -> Result<(), Error> {
8549 self.request_empty(
8550 Method::DELETE,
8551 &["assets", asset_uuid, "delete"],
8552 None::<&()>,
8553 )
8554 .await
8555 }
8556
8557 /// # Errors
8558 /// Returns an error if:
8559 /// - The transaction ID is invalid or not found
8560 /// - Authentication fails or token is invalid
8561 /// - Network connectivity issues occur
8562 /// - The server returns an error status
8563 /// - The response cannot be parsed
8564 pub async fn get_broadcast_status(&self, txid: &str) -> Result<BroadcastResponse, Error> {
8565 self.request_json(Method::GET, &["tx", "broadcast", txid], None::<&()>)
8566 .await
8567 }
8568
8569 /// # Errors
8570 /// Returns an error if:
8571 /// - The transaction hex is invalid or malformed
8572 /// - The transaction is rejected by the network
8573 /// - Authentication fails or token is invalid
8574 /// - Network connectivity issues occur
8575 /// - The server returns an error status
8576 /// - The response cannot be parsed
8577 pub async fn broadcast_transaction(&self, tx_hex: &str) -> Result<BroadcastResponse, Error> {
8578 self.request_json(Method::POST, &["tx", "broadcast"], Some(tx_hex))
8579 .await
8580 }
8581
8582 /// # Errors
8583 /// Returns an error if:
8584 /// - The asset UUID is invalid or not found
8585 /// - The user lacks authorization to register the asset
8586 /// - The asset is already registered
8587 /// - Authentication fails or token is invalid
8588 /// - Network connectivity issues occur
8589 /// - The server returns an error status
8590 /// - The response cannot be parsed
8591 pub async fn register_asset_authorized(&self, asset_uuid: &str) -> Result<Asset, Error> {
8592 self.request_json(
8593 Method::GET,
8594 &["assets", asset_uuid, "register-authorized"],
8595 None::<&()>,
8596 )
8597 .await
8598 }
8599
8600 /// # Errors
8601 /// Returns an error if:
8602 /// - The asset UUID is invalid or not found
8603 /// - The asset is already locked
8604 /// - The user lacks permission to lock the asset
8605 /// - Authentication fails or token is invalid
8606 /// - Network connectivity issues occur
8607 /// - The server returns an error status
8608 /// - The response cannot be parsed
8609 pub async fn lock_asset(&self, asset_uuid: &str) -> Result<Asset, Error> {
8610 self.request_json(Method::PUT, &["assets", asset_uuid, "lock"], None::<&()>)
8611 .await
8612 }
8613
8614 /// # Errors
8615 /// Returns an error if:
8616 /// - The asset UUID is invalid or not found
8617 /// - The asset is not currently locked
8618 /// - The user lacks permission to unlock the asset
8619 /// - Authentication fails or token is invalid
8620 /// - Network connectivity issues occur
8621 /// - The server returns an error status
8622 /// - The response cannot be parsed
8623 pub async fn unlock_asset(&self, asset_uuid: &str) -> Result<Asset, Error> {
8624 self.request_json(Method::PUT, &["assets", asset_uuid, "unlock"], None::<&()>)
8625 .await
8626 }
8627
8628 /// # Errors
8629 /// Returns an error if:
8630 /// - The asset UUID is invalid or not found
8631 /// - The activity parameters are invalid
8632 /// - Authentication fails or token is invalid
8633 /// - Network connectivity issues occur
8634 /// - The server returns an error status
8635 /// - The response cannot be parsed
8636 pub async fn get_asset_activities(
8637 &self,
8638 asset_uuid: &str,
8639 params: &AssetActivityParams,
8640 ) -> Result<Vec<Activity>, Error> {
8641 self.request_json(
8642 Method::GET,
8643 &["assets", asset_uuid, "activities"],
8644 Some(params),
8645 )
8646 .await
8647 }
8648
8649 /// # Errors
8650 /// Returns an error if:
8651 /// - The asset UUID is invalid or not found
8652 /// - The specified height is invalid or out of range
8653 /// - Authentication fails or token is invalid
8654 /// - Network connectivity issues occur
8655 /// - The server returns an error status
8656 /// - The response cannot be parsed
8657 pub async fn get_asset_ownerships(
8658 &self,
8659 asset_uuid: &str,
8660 height: Option<i64>,
8661 ) -> Result<Vec<Ownership>, Error> {
8662 let mut path = vec!["assets", asset_uuid, "ownerships"];
8663 let height_str;
8664 if let Some(h) = height {
8665 height_str = h.to_string();
8666 path.push(&height_str);
8667 }
8668 self.request_json(Method::GET, &path, None::<&()>).await
8669 }
8670
8671 /// # Errors
8672 /// Returns an error if:
8673 /// - The asset UUID is invalid or not found
8674 /// - Authentication fails or token is invalid
8675 /// - Network connectivity issues occur
8676 /// - The server returns an error status
8677 /// - The response cannot be parsed
8678 pub async fn get_asset_balance(&self, asset_uuid: &str) -> Result<Balance, Error> {
8679 self.request_json(Method::GET, &["assets", asset_uuid, "balance"], None::<&()>)
8680 .await
8681 }
8682
8683 /// # Errors
8684 /// Returns an error if:
8685 /// - The asset UUID is invalid or not found
8686 /// - Authentication fails or token is invalid
8687 /// - Network connectivity issues occur
8688 /// - The server returns an error status
8689 /// - The response cannot be parsed
8690 pub async fn get_asset_summary(&self, asset_uuid: &str) -> Result<AssetSummary, Error> {
8691 self.request_json(Method::GET, &["assets", asset_uuid, "summary"], None::<&()>)
8692 .await
8693 }
8694
8695 /// # Errors
8696 /// Returns an error if:
8697 /// - The asset UUID is invalid or not found
8698 /// - Authentication fails or token is invalid
8699 /// - Network connectivity issues occur
8700 /// - The server returns an error status
8701 /// - The response cannot be parsed
8702 pub async fn get_asset_utxos(&self, asset_uuid: &str) -> Result<Vec<Utxo>, Error> {
8703 self.request_json(Method::GET, &["assets", asset_uuid, "utxos"], None::<&()>)
8704 .await
8705 }
8706
8707 /// Gets the memo for a specific asset.
8708 ///
8709 /// # Arguments
8710 /// * `asset_uuid` - The UUID of the asset to retrieve the memo for
8711 ///
8712 /// # Returns
8713 /// The memo string associated with the asset
8714 ///
8715 /// # Errors
8716 /// Returns an error if:
8717 /// - Authentication fails
8718 /// - The HTTP request fails
8719 /// - The server returns an error status
8720 /// - The asset does not exist
8721 /// - The response cannot be parsed
8722 pub async fn get_asset_memo(&self, asset_uuid: &str) -> Result<String, Error> {
8723 self.request_json(Method::GET, &["assets", asset_uuid, "memo"], None::<&()>)
8724 .await
8725 }
8726
8727 /// Sets a memo for the specified asset.
8728 ///
8729 /// # Arguments
8730 /// * `asset_uuid` - The UUID of the asset to set the memo for
8731 /// * `memo` - The memo string to associate with the asset
8732 ///
8733 /// # Returns
8734 /// Returns `Ok(())` on success.
8735 ///
8736 /// # Errors
8737 /// Returns an error if:
8738 /// - Authentication fails
8739 /// - The HTTP request fails
8740 /// - The server returns an error status
8741 /// - The asset does not exist
8742 /// - The memo cannot be set due to validation errors
8743 ///
8744 /// # Example
8745 /// ```rust
8746 /// # use amp_rs::ApiClient;
8747 /// # async fn example(client: &ApiClient) -> Result<(), Box<dyn std::error::Error>> {
8748 /// client.set_asset_memo("asset-uuid-123", "This is a memo for the asset").await?;
8749 /// # Ok(())
8750 /// # }
8751 /// ```
8752 pub async fn set_asset_memo(&self, asset_uuid: &str, memo: &str) -> Result<(), Error> {
8753 let token = self.get_token().await?;
8754 let mut url = self.base_url.clone();
8755 url.path_segments_mut()
8756 .unwrap()
8757 .extend(&["assets", asset_uuid, "memo", "set"]);
8758
8759 let response = self
8760 .client
8761 .request(Method::POST, url)
8762 .header(AUTHORIZATION, format!("token {token}"))
8763 .header("content-type", "application/json")
8764 .body(format!("\"{}\"", memo.replace('"', "\\\"")))
8765 .send()
8766 .await?;
8767
8768 if !response.status().is_success() {
8769 let status = response.status();
8770 let error_text = response
8771 .text()
8772 .await
8773 .unwrap_or_else(|_| "Unknown error".to_string());
8774 return Err(Error::RequestFailed(format!(
8775 "Request to [\"assets\", \"{asset_uuid}\", \"memo\", \"set\"] failed with status {status}: {error_text}"
8776 )));
8777 }
8778
8779 Ok(())
8780 }
8781
8782 /// Blacklists specific UTXOs for an asset to prevent them from being used in transactions.
8783 ///
8784 /// This method adds the specified UTXOs to the asset's blacklist, preventing them from being
8785 /// used in future transactions. This is typically used for security purposes when UTXOs are
8786 /// suspected to be compromised or need to be temporarily disabled.
8787 ///
8788 /// # Arguments
8789 /// * `asset_uuid` - The UUID of the asset to blacklist UTXOs for
8790 /// * `utxos` - A slice of `Outpoint` structs representing the UTXOs to blacklist
8791 ///
8792 /// # Returns
8793 /// Returns a vector of `Utxo` structs representing the blacklisted UTXOs with their updated status.
8794 ///
8795 /// # Errors
8796 /// Returns an error if:
8797 /// - Authentication fails or insufficient permissions
8798 /// - The asset UUID is invalid or does not exist
8799 /// - One or more UTXOs are invalid or already blacklisted
8800 /// - The HTTP request fails
8801 /// - The server returns an error status
8802 /// - The response cannot be parsed
8803 ///
8804 /// # Examples
8805 /// ```no_run
8806 /// # use amp_rs::{ApiClient, model::Outpoint};
8807 /// # #[tokio::main]
8808 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8809 /// let client = ApiClient::new().await?;
8810 ///
8811 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
8812 /// let utxos = vec![
8813 /// Outpoint {
8814 /// txid: "abc123...".to_string(),
8815 /// vout: 0,
8816 /// },
8817 /// Outpoint {
8818 /// txid: "def456...".to_string(),
8819 /// vout: 1,
8820 /// },
8821 /// ];
8822 ///
8823 /// let blacklisted_utxos = client.blacklist_asset_utxos(asset_uuid, &utxos).await?;
8824 /// println!("Blacklisted {} UTXOs", blacklisted_utxos.len());
8825 /// # Ok(())
8826 /// # }
8827 /// ```
8828 ///
8829 /// # Related Methods
8830 /// - [`whitelist_asset_utxos`](Self::whitelist_asset_utxos) - Remove UTXOs from blacklist
8831 /// - [`get_asset`](Self::get_asset) - Get asset information including UTXO status
8832 pub async fn blacklist_asset_utxos(
8833 &self,
8834 asset_uuid: &str,
8835 utxos: &[Outpoint],
8836 ) -> Result<Vec<Utxo>, Error> {
8837 self.request_json(
8838 Method::POST,
8839 &["assets", asset_uuid, "utxos", "blacklist"],
8840 Some(utxos),
8841 )
8842 .await
8843 }
8844
8845 /// Removes UTXOs from the asset's blacklist, allowing them to be used in transactions again.
8846 ///
8847 /// This method removes the specified UTXOs from the asset's blacklist, restoring their ability
8848 /// to be used in transactions. This is the reverse operation of blacklisting UTXOs.
8849 ///
8850 /// # Arguments
8851 /// * `asset_uuid` - The UUID of the asset to whitelist UTXOs for
8852 /// * `utxos` - A slice of `Outpoint` structs representing the UTXOs to remove from blacklist
8853 ///
8854 /// # Returns
8855 /// Returns a vector of `Utxo` structs representing the whitelisted UTXOs with their updated status.
8856 ///
8857 /// # Errors
8858 /// Returns an error if:
8859 /// - Authentication fails or insufficient permissions
8860 /// - The asset UUID is invalid or does not exist
8861 /// - One or more UTXOs are invalid or not currently blacklisted
8862 /// - The HTTP request fails
8863 /// - The server returns an error status
8864 /// - The response cannot be parsed
8865 ///
8866 /// # Examples
8867 /// ```no_run
8868 /// # use amp_rs::{ApiClient, model::Outpoint};
8869 /// # #[tokio::main]
8870 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8871 /// let client = ApiClient::new().await?;
8872 ///
8873 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
8874 /// let utxos = vec![
8875 /// Outpoint {
8876 /// txid: "abc123...".to_string(),
8877 /// vout: 0,
8878 /// },
8879 /// ];
8880 ///
8881 /// let whitelisted_utxos = client.whitelist_asset_utxos(asset_uuid, &utxos).await?;
8882 /// println!("Whitelisted {} UTXOs", whitelisted_utxos.len());
8883 /// # Ok(())
8884 /// # }
8885 /// ```
8886 ///
8887 /// # Related Methods
8888 /// - [`blacklist_asset_utxos`](Self::blacklist_asset_utxos) - Add UTXOs to blacklist
8889 /// - [`get_asset`](Self::get_asset) - Get asset information including UTXO status
8890 pub async fn whitelist_asset_utxos(
8891 &self,
8892 asset_uuid: &str,
8893 utxos: &[Outpoint],
8894 ) -> Result<Vec<Utxo>, Error> {
8895 self.request_json(
8896 Method::POST,
8897 &["assets", asset_uuid, "utxos", "whitelist"],
8898 Some(utxos),
8899 )
8900 .await
8901 }
8902
8903 /// Gets the treasury addresses for a specific asset
8904 ///
8905 /// # Arguments
8906 /// * `asset_uuid` - The UUID of the asset to get treasury addresses for
8907 ///
8908 /// # Returns
8909 /// A vector of treasury addresses as strings
8910 ///
8911 /// # Errors
8912 /// Returns an error if:
8913 /// - The asset does not exist
8914 /// - The request fails
8915 /// - The response cannot be parsed
8916 pub async fn get_asset_treasury_addresses(
8917 &self,
8918 asset_uuid: &str,
8919 ) -> Result<Vec<String>, Error> {
8920 self.request_json(
8921 Method::GET,
8922 &["assets", asset_uuid, "treasury-addresses"],
8923 None::<&()>,
8924 )
8925 .await
8926 }
8927
8928 /// Adds treasury addresses to a specific asset
8929 ///
8930 /// # Arguments
8931 /// * `asset_uuid` - The UUID of the asset to add treasury addresses to
8932 /// * `addresses` - A slice of address strings to add as treasury addresses
8933 ///
8934 /// # Returns
8935 /// Returns `Ok(())` on success
8936 ///
8937 /// # Errors
8938 /// Returns an error if:
8939 /// - The asset does not exist
8940 /// - The addresses are invalid
8941 /// - The request fails
8942 /// - Insufficient permissions
8943 pub async fn add_asset_treasury_addresses(
8944 &self,
8945 asset_uuid: &str,
8946 addresses: &[String],
8947 ) -> Result<(), Error> {
8948 self.request_empty(
8949 Method::POST,
8950 &["assets", asset_uuid, "treasury-addresses", "add"],
8951 Some(addresses),
8952 )
8953 .await
8954 }
8955
8956 /// Removes treasury addresses from a specific asset.
8957 ///
8958 /// This method removes the specified addresses from the asset's treasury address list.
8959 /// Treasury addresses are special addresses that can be used for asset management operations
8960 /// such as reissuance and burning.
8961 ///
8962 /// # Arguments
8963 /// * `asset_uuid` - The UUID of the asset to remove treasury addresses from
8964 /// * `addresses` - A slice of address strings to remove from the treasury addresses
8965 ///
8966 /// # Returns
8967 /// Returns `Ok(())` on successful removal.
8968 ///
8969 /// # Errors
8970 /// Returns an error if:
8971 /// - Authentication fails or insufficient permissions
8972 /// - The asset UUID is invalid or does not exist
8973 /// - One or more addresses are invalid or not currently treasury addresses
8974 /// - The HTTP request fails
8975 /// - The server returns an error status
8976 /// - Attempting to remove the last treasury address (if not allowed)
8977 ///
8978 /// # Examples
8979 /// ```no_run
8980 /// # use amp_rs::ApiClient;
8981 /// # #[tokio::main]
8982 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8983 /// let client = ApiClient::new().await?;
8984 ///
8985 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
8986 /// let addresses = vec![
8987 /// "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
8988 /// "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(),
8989 /// ];
8990 ///
8991 /// client.delete_asset_treasury_addresses(asset_uuid, &addresses).await?;
8992 /// println!("Removed {} treasury addresses", addresses.len());
8993 /// # Ok(())
8994 /// # }
8995 /// ```
8996 ///
8997 /// # Related Methods
8998 /// - [`add_asset_treasury_addresses`](Self::add_asset_treasury_addresses) - Add treasury addresses
8999 /// - [`get_asset_treasury_addresses`](Self::get_asset_treasury_addresses) - Get current treasury addresses
9000 /// - [`reissue_asset`](Self::reissue_asset) - Reissue assets using treasury addresses
9001 pub async fn delete_asset_treasury_addresses(
9002 &self,
9003 asset_uuid: &str,
9004 addresses: &[String],
9005 ) -> Result<(), Error> {
9006 self.request_empty(
9007 Method::DELETE,
9008 &["assets", asset_uuid, "treasury-addresses", "delete"],
9009 Some(addresses),
9010 )
9011 .await
9012 }
9013
9014 /// Gets a list of all registered users.
9015 ///
9016 /// # Errors
9017 /// Returns an error if:
9018 /// - Authentication fails
9019 /// - The HTTP request fails
9020 /// - The server returns an error status
9021 /// - The response cannot be parsed
9022 ///
9023 /// # Examples
9024 /// ```no_run
9025 /// # use amp_rs::ApiClient;
9026 /// # #[tokio::main]
9027 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9028 /// let client = ApiClient::new().await?;
9029 ///
9030 /// let users = client.get_registered_users().await?;
9031 /// for user in users {
9032 /// println!("User: {} (ID: {})", user.name, user.id);
9033 /// }
9034 /// # Ok(())
9035 /// # }
9036 /// ```
9037 pub async fn get_registered_users(
9038 &self,
9039 ) -> Result<Vec<crate::model::RegisteredUserResponse>, Error> {
9040 self.request_json(Method::GET, &["registered_users"], None::<&()>)
9041 .await
9042 }
9043
9044 /// Gets a specific registered user by ID.
9045 ///
9046 /// # Arguments
9047 /// * `user_id` - The ID of the registered user to retrieve
9048 ///
9049 /// # Errors
9050 /// Returns an error if:
9051 /// - Authentication fails
9052 /// - The HTTP request fails
9053 /// - The user ID does not exist
9054 /// - The response cannot be parsed
9055 ///
9056 /// # Examples
9057 /// ```no_run
9058 /// # use amp_rs::ApiClient;
9059 /// # #[tokio::main]
9060 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9061 /// let client = ApiClient::new().await?;
9062 ///
9063 /// let user = client.get_registered_user(1).await?;
9064 /// println!("User: {} (ID: {})", user.name, user.id);
9065 /// # Ok(())
9066 /// # }
9067 /// ```
9068 pub async fn get_registered_user(
9069 &self,
9070 user_id: i64,
9071 ) -> Result<crate::model::RegisteredUserResponse, Error> {
9072 self.request_json(
9073 Method::GET,
9074 &["registered_users", &user_id.to_string()],
9075 None::<&()>,
9076 )
9077 .await
9078 }
9079
9080 /// Creates a new registered user in the AMP system.
9081 ///
9082 /// This method creates a new registered user with the provided information. Registered users
9083 /// can be associated with GAIDs, assigned to categories, and receive asset assignments.
9084 ///
9085 /// # Arguments
9086 /// * `new_user` - A `RegisteredUserAdd` struct containing the user information to create
9087 ///
9088 /// # Returns
9089 /// Returns a `RegisteredUserResponse` containing the created user's information including
9090 /// the assigned user ID.
9091 ///
9092 /// # Errors
9093 /// Returns an error if:
9094 /// - Authentication fails or insufficient permissions
9095 /// - The user data is invalid (e.g., missing required fields, invalid email format)
9096 /// - A user with the same identifier already exists
9097 /// - The HTTP request fails
9098 /// - The server returns an error status
9099 /// - The response cannot be parsed
9100 ///
9101 /// # Examples
9102 /// ```no_run
9103 /// # use amp_rs::{ApiClient, model::RegisteredUserAdd};
9104 /// # #[tokio::main]
9105 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9106 /// let client = ApiClient::new().await?;
9107 ///
9108 /// let new_user = RegisteredUserAdd {
9109 /// name: "John Doe".to_string(),
9110 /// gaid: Some("GAbYScu6jkWUND2jo3L4KJxyvo55d".to_string()),
9111 /// is_company: false,
9112 /// };
9113 ///
9114 /// let created_user = client.add_registered_user(&new_user).await?;
9115 /// println!("Created user: {} with ID {}", created_user.name, created_user.id);
9116 /// # Ok(())
9117 /// # }
9118 /// ```
9119 ///
9120 /// # Related Methods
9121 /// - [`get_registered_users`](Self::get_registered_users) - List all registered users
9122 /// - [`edit_registered_user`](Self::edit_registered_user) - Update user information
9123 /// - [`delete_registered_user`](Self::delete_registered_user) - Remove a user
9124 pub async fn add_registered_user(
9125 &self,
9126 new_user: &crate::model::RegisteredUserAdd,
9127 ) -> Result<crate::model::RegisteredUserResponse, Error> {
9128 self.request_json(Method::POST, &["registered_users", "add"], Some(new_user))
9129 .await
9130 }
9131
9132 /// Removes a registered user from the AMP system.
9133 ///
9134 /// This method permanently deletes a registered user and all associated data. This operation
9135 /// cannot be undone. Any GAIDs associated with the user will be disassociated, and any
9136 /// pending assignments may be affected.
9137 ///
9138 /// # Arguments
9139 /// * `user_id` - The ID of the registered user to delete
9140 ///
9141 /// # Returns
9142 /// Returns `Ok(())` on successful deletion.
9143 ///
9144 /// # Errors
9145 /// Returns an error if:
9146 /// - Authentication fails or insufficient permissions
9147 /// - The user ID is invalid or does not exist
9148 /// - The user has active assignments that prevent deletion
9149 /// - The HTTP request fails
9150 /// - The server returns an error status
9151 ///
9152 /// # Examples
9153 /// ```no_run
9154 /// # use amp_rs::ApiClient;
9155 /// # #[tokio::main]
9156 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9157 /// let client = ApiClient::new().await?;
9158 ///
9159 /// let user_id = 123;
9160 /// client.delete_registered_user(user_id).await?;
9161 /// println!("Successfully deleted user with ID {}", user_id);
9162 /// # Ok(())
9163 /// # }
9164 /// ```
9165 ///
9166 /// # Related Methods
9167 /// - [`get_registered_user`](Self::get_registered_user) - Get user information before deletion
9168 /// - [`add_registered_user`](Self::add_registered_user) - Create a new user
9169 /// - [`get_registered_user_summary`](Self::get_registered_user_summary) - Check user's assignments
9170 pub async fn delete_registered_user(&self, user_id: i64) -> Result<(), Error> {
9171 self.request_empty(
9172 Method::DELETE,
9173 &["registered_users", &user_id.to_string(), "delete"],
9174 None::<&()>,
9175 )
9176 .await
9177 }
9178
9179 /// Updates registered user information.
9180 ///
9181 /// This method allows you to modify the information of an existing registered user.
9182 /// Only the fields provided in the edit data will be updated; other fields remain unchanged.
9183 ///
9184 /// # Arguments
9185 /// * `registered_user_id` - The ID of the registered user to update
9186 /// * `edit_data` - A `RegisteredUserEdit` struct containing the fields to update
9187 ///
9188 /// # Returns
9189 /// Returns a `RegisteredUserResponse` containing the updated user information.
9190 ///
9191 /// # Errors
9192 /// Returns an error if:
9193 /// - Authentication fails or insufficient permissions
9194 /// - The user ID is invalid or does not exist
9195 /// - The edit data contains invalid values (e.g., invalid email format)
9196 /// - The HTTP request fails
9197 /// - The server returns an error status
9198 /// - The response cannot be parsed
9199 ///
9200 /// # Examples
9201 /// ```no_run
9202 /// # use amp_rs::{ApiClient, model::RegisteredUserEdit};
9203 /// # #[tokio::main]
9204 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9205 /// let client = ApiClient::new().await?;
9206 ///
9207 /// let user_id = 123;
9208 /// let edit_data = RegisteredUserEdit {
9209 /// name: Some("Jane Doe".to_string()),
9210 /// };
9211 ///
9212 /// let updated_user = client.edit_registered_user(user_id, &edit_data).await?;
9213 /// println!("Updated user: {}", updated_user.name);
9214 /// # Ok(())
9215 /// # }
9216 /// ```
9217 ///
9218 /// # Related Methods
9219 /// - [`get_registered_user`](Self::get_registered_user) - Get current user information
9220 /// - [`add_registered_user`](Self::add_registered_user) - Create a new user
9221 /// - [`delete_registered_user`](Self::delete_registered_user) - Remove a user
9222 pub async fn edit_registered_user(
9223 &self,
9224 registered_user_id: i64,
9225 edit_data: &crate::model::RegisteredUserEdit,
9226 ) -> Result<crate::model::RegisteredUserResponse, Error> {
9227 self.request_json(
9228 Method::PUT,
9229 &["registered_users", ®istered_user_id.to_string(), "edit"],
9230 Some(edit_data),
9231 )
9232 .await
9233 }
9234
9235 /// Gets comprehensive summary information for a registered user including assets and distributions.
9236 ///
9237 /// This method retrieves detailed summary information about a registered user, including
9238 /// their basic information, associated assets, assignment history, and distribution records.
9239 /// This provides a complete overview of the user's activity and holdings in the system.
9240 ///
9241 /// # Arguments
9242 /// * `registered_user_id` - The ID of the registered user to get summary for
9243 ///
9244 /// # Returns
9245 /// Returns a `RegisteredUserSummary` containing:
9246 /// - Basic user information (name, email, etc.)
9247 /// - List of associated GAIDs
9248 /// - Asset assignments and their status
9249 /// - Distribution history
9250 /// - Balance information
9251 /// - Activity timestamps
9252 ///
9253 /// # Errors
9254 /// Returns an error if:
9255 /// - Authentication fails or insufficient permissions
9256 /// - The user ID is invalid or does not exist
9257 /// - The HTTP request fails
9258 /// - The server returns an error status
9259 /// - The response cannot be parsed
9260 ///
9261 /// # Examples
9262 /// ```no_run
9263 /// # use amp_rs::ApiClient;
9264 /// # #[tokio::main]
9265 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9266 /// let client = ApiClient::new().await?;
9267 ///
9268 /// let user_id = 123;
9269 /// let summary = client.get_registered_user_summary(user_id).await?;
9270 ///
9271 /// println!("Asset UUID: {}", summary.asset_uuid);
9272 /// println!("Asset ID: {}", summary.asset_id);
9273 /// println!("Asset assignments: {}", summary.assignments.len());
9274 /// println!("Distributions received: {}", summary.distributions.len());
9275 /// # Ok(())
9276 /// # }
9277 /// ```
9278 ///
9279 /// # Related Methods
9280 /// - [`get_registered_user`](Self::get_registered_user) - Get basic user information
9281 /// - [`get_registered_user_gaids`](Self::get_registered_user_gaids) - Get only GAIDs
9282 /// - [`get_asset_assignments`](Self::get_asset_assignments) - Get assignments for specific asset
9283 pub async fn get_registered_user_summary(
9284 &self,
9285 registered_user_id: i64,
9286 ) -> Result<crate::model::RegisteredUserSummary, Error> {
9287 self.request_json(
9288 Method::GET,
9289 &[
9290 "registered_users",
9291 ®istered_user_id.to_string(),
9292 "summary",
9293 ],
9294 None::<&()>,
9295 )
9296 .await
9297 }
9298
9299 /// Gets all GAIDs (Green Address IDs) associated with a registered user.
9300 ///
9301 /// This method retrieves a list of all GAIDs that are currently associated with the specified
9302 /// registered user. GAIDs are unique identifiers that can be used to receive assets and
9303 /// track ownership.
9304 ///
9305 /// # Arguments
9306 /// * `registered_user_id` - The ID of the registered user to get GAIDs for
9307 ///
9308 /// # Returns
9309 /// Returns a vector of GAID strings associated with the user.
9310 ///
9311 /// # Errors
9312 /// Returns an error if:
9313 /// - Authentication fails or insufficient permissions
9314 /// - The user ID is invalid or does not exist
9315 /// - The HTTP request fails
9316 /// - The server returns an error status
9317 /// - The response cannot be parsed
9318 ///
9319 /// # Examples
9320 /// ```no_run
9321 /// # use amp_rs::ApiClient;
9322 /// # #[tokio::main]
9323 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9324 /// let client = ApiClient::new().await?;
9325 ///
9326 /// let user_id = 123;
9327 /// let gaids = client.get_registered_user_gaids(user_id).await?;
9328 ///
9329 /// println!("User {} has {} associated GAIDs:", user_id, gaids.len());
9330 /// for gaid in gaids {
9331 /// println!(" - {}", gaid);
9332 /// }
9333 /// # Ok(())
9334 /// # }
9335 /// ```
9336 ///
9337 /// # Related Methods
9338 /// - [`add_gaid_to_registered_user`](Self::add_gaid_to_registered_user) - Associate a GAID with user
9339 /// - [`set_default_gaid_for_registered_user`](Self::set_default_gaid_for_registered_user) - Set default GAID
9340 /// - [`get_gaid_registered_user`](Self::get_gaid_registered_user) - Find user by GAID
9341 /// - [`validate_gaid`](Self::validate_gaid) - Validate GAID format
9342 pub async fn get_registered_user_gaids(
9343 &self,
9344 registered_user_id: i64,
9345 ) -> Result<Vec<String>, Error> {
9346 self.request_json(
9347 Method::GET,
9348 &["registered_users", ®istered_user_id.to_string(), "gaids"],
9349 None::<&()>,
9350 )
9351 .await
9352 }
9353
9354 /// Associates a GAID with a registered user.
9355 ///
9356 /// # Arguments
9357 /// * `registered_user_id` - The ID of the registered user
9358 /// * `gaid` - The GAID to associate with the user
9359 ///
9360 /// # Errors
9361 ///
9362 /// Returns an error if:
9363 /// - Authentication fails
9364 /// - The HTTP request fails
9365 /// - The server returns an error status
9366 /// - The registered user ID is invalid
9367 /// - The GAID is invalid or already associated
9368 pub async fn add_gaid_to_registered_user(
9369 &self,
9370 registered_user_id: i64,
9371 gaid: &str,
9372 ) -> Result<(), Error> {
9373 // Send GAID as a plain string, not wrapped in an object
9374 self.request_empty(
9375 Method::POST,
9376 &[
9377 "registered_users",
9378 ®istered_user_id.to_string(),
9379 "gaids",
9380 "add",
9381 ],
9382 Some(gaid),
9383 )
9384 .await
9385 }
9386
9387 /// Sets an existing GAID as the default for a registered user.
9388 ///
9389 /// This method allows you to designate a specific GAID as the primary/default
9390 /// GAID for a registered user. The GAID must already be associated with the user.
9391 ///
9392 /// # Arguments
9393 /// * `registered_user_id` - The ID of the registered user
9394 /// * `gaid` - The GAID to set as default
9395 ///
9396 /// # Returns
9397 /// Returns `Ok(())` if the operation is successful.
9398 ///
9399 /// # Errors
9400 /// Returns an error if:
9401 /// - Authentication fails
9402 /// - The HTTP request fails
9403 /// - The server returns an error status
9404 /// - The registered user ID is invalid
9405 /// - The GAID is not associated with the user
9406 pub async fn set_default_gaid_for_registered_user(
9407 &self,
9408 registered_user_id: i64,
9409 gaid: &str,
9410 ) -> Result<(), Error> {
9411 // Send GAID as a plain string, not wrapped in an object
9412 self.request_empty(
9413 Method::POST,
9414 &[
9415 "registered_users",
9416 ®istered_user_id.to_string(),
9417 "gaids",
9418 "set-default",
9419 ],
9420 Some(gaid),
9421 )
9422 .await
9423 }
9424
9425 /// Retrieves the registered user associated with a GAID
9426 ///
9427 /// # Arguments
9428 /// * `gaid` - The GAID to look up
9429 ///
9430 /// # Returns
9431 /// Returns the registered user data if the GAID is associated with a user
9432 ///
9433 /// # Errors
9434 /// This function will return an error if:
9435 /// - The GAID has no associated user
9436 /// - The GAID is invalid
9437 /// - Network or authentication errors occur
9438 pub async fn get_gaid_registered_user(
9439 &self,
9440 gaid: &str,
9441 ) -> Result<crate::model::RegisteredUserResponse, Error> {
9442 self.request_json(
9443 Method::GET,
9444 &["gaids", gaid, "registered_user"],
9445 None::<&()>,
9446 )
9447 .await
9448 }
9449
9450 /// Gets the balance information for a specific GAID.
9451 ///
9452 /// This method retrieves all asset balances associated with the given GAID,
9453 /// including confirmed balances and any lost outputs.
9454 ///
9455 /// # Arguments
9456 /// * `gaid` - The GAID to query balance for
9457 ///
9458 /// # Returns
9459 /// Returns a `Balance` struct containing confirmed balances and lost outputs
9460 ///
9461 /// # Errors
9462 /// Returns an error if:
9463 /// - The GAID is invalid
9464 /// - Network or authentication errors occur
9465 /// - The response cannot be parsed
9466 ///
9467 /// # Examples
9468 /// ```no_run
9469 /// # use amp_rs::ApiClient;
9470 /// # #[tokio::main]
9471 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9472 /// let client = ApiClient::new().await?;
9473 ///
9474 /// let gaid = "GAbYScu6jkWUND2jo3L4KJxyvo55d";
9475 /// let balance = client.get_gaid_balance(gaid).await?;
9476 ///
9477 /// println!("GAID {} has {} balance entries", gaid, balance.len());
9478 /// for entry in balance {
9479 /// println!("Asset {}: {} units", entry.asset_id, entry.balance);
9480 /// }
9481 /// # Ok(())
9482 /// # }
9483 /// ```
9484 pub async fn get_gaid_balance(&self, gaid: &str) -> Result<Balance, Error> {
9485 self.request_json(Method::GET, &["gaids", gaid, "balance"], None::<&()>)
9486 .await
9487 }
9488
9489 /// Retrieves the specific asset balance for a GAID
9490 ///
9491 /// # Arguments
9492 /// * `gaid` - The GAID to query
9493 /// * `asset_uuid` - The UUID of the asset to query
9494 ///
9495 /// # Returns
9496 /// Returns the specific asset balance information
9497 ///
9498 /// # Errors
9499 /// Returns an error if:
9500 /// - The GAID is invalid
9501 /// - The asset UUID is invalid
9502 /// - Network or authentication errors occur
9503 /// - The response cannot be parsed
9504 pub async fn get_gaid_asset_balance(
9505 &self,
9506 gaid: &str,
9507 asset_uuid: &str,
9508 ) -> Result<Ownership, Error> {
9509 // Try to get the response as a GaidBalanceEntry first, then convert to Ownership
9510 let balance_entry: GaidBalanceEntry = self
9511 .request_json(
9512 Method::GET,
9513 &["gaids", gaid, "balance", asset_uuid],
9514 None::<&()>,
9515 )
9516 .await?;
9517
9518 // Convert GaidBalanceEntry to Ownership format
9519 Ok(Ownership {
9520 owner: gaid.to_string(),
9521 amount: balance_entry.balance,
9522 gaid: Some(gaid.to_string()),
9523 })
9524 }
9525
9526 /// Gets a list of all categories.
9527 ///
9528 /// # Returns
9529 /// Returns a vector of `CategoryResponse` objects
9530 ///
9531 /// # Errors
9532 /// Returns an error if:
9533 /// - Authentication fails
9534 /// - The HTTP request fails
9535 /// - The response cannot be parsed
9536 ///
9537 /// # Examples
9538 /// ```no_run
9539 /// # use amp_rs::ApiClient;
9540 /// # #[tokio::main]
9541 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9542 /// let client = ApiClient::new().await?;
9543 ///
9544 /// let categories = client.get_categories().await?;
9545 /// for category in categories {
9546 /// println!("Category: {} (ID: {})", category.name, category.id);
9547 /// if let Some(desc) = category.description {
9548 /// println!(" Description: {}", desc);
9549 /// }
9550 /// }
9551 /// # Ok(())
9552 /// # }
9553 /// ```
9554 pub async fn get_categories(&self) -> Result<Vec<CategoryResponse>, Error> {
9555 self.request_json(Method::GET, &["categories"], None::<&()>)
9556 .await
9557 }
9558
9559 /// Creates a new category for organizing users and assets.
9560 ///
9561 /// This method creates a new category that can be used to group registered users and assets
9562 /// for organizational purposes. Categories help manage permissions and provide logical
9563 /// groupings for assets and users.
9564 ///
9565 /// # Arguments
9566 /// * `new_category` - A `CategoryAdd` struct containing the category information to create
9567 ///
9568 /// # Returns
9569 /// Returns a `CategoryResponse` containing the created category information including
9570 /// the assigned category ID.
9571 ///
9572 /// # Errors
9573 /// Returns an error if:
9574 /// - Authentication fails or insufficient permissions
9575 /// - The category data is invalid (e.g., missing name, invalid characters)
9576 /// - A category with the same name already exists
9577 /// - The HTTP request fails
9578 /// - The server returns an error status
9579 /// - The response cannot be parsed
9580 ///
9581 /// # Examples
9582 /// ```no_run
9583 /// # use amp_rs::{ApiClient, model::CategoryAdd};
9584 /// # #[tokio::main]
9585 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9586 /// let client = ApiClient::new().await?;
9587 ///
9588 /// let new_category = CategoryAdd {
9589 /// name: "Premium Users".to_string(),
9590 /// description: Some("High-value users with special privileges".to_string()),
9591 /// };
9592 ///
9593 /// let created_category = client.add_category(&new_category).await?;
9594 /// println!("Created category: {} with ID {}", created_category.name, created_category.id);
9595 /// # Ok(())
9596 /// # }
9597 /// ```
9598 ///
9599 /// # Related Methods
9600 /// - [`get_categories`](Self::get_categories) - List all categories
9601 /// - [`edit_category`](Self::edit_category) - Update category information
9602 /// - [`delete_category`](Self::delete_category) - Remove a category
9603 /// - [`add_registered_user_to_category`](Self::add_registered_user_to_category) - Add users to category
9604 pub async fn add_category(
9605 &self,
9606 new_category: &CategoryAdd,
9607 ) -> Result<CategoryResponse, Error> {
9608 self.request_json(Method::POST, &["categories", "add"], Some(new_category))
9609 .await
9610 }
9611
9612 /// Gets a specific category by ID.
9613 ///
9614 /// This method retrieves detailed information about a specific category, including
9615 /// its name, description, and associated users and assets.
9616 ///
9617 /// # Arguments
9618 /// * `category_id` - The ID of the category to retrieve
9619 ///
9620 /// # Returns
9621 /// Returns a `CategoryResponse` containing the category information including:
9622 /// - Category ID, name, and description
9623 /// - List of associated registered users
9624 /// - List of associated assets
9625 /// - Creation and modification timestamps
9626 ///
9627 /// # Errors
9628 /// Returns an error if:
9629 /// - Authentication fails or insufficient permissions
9630 /// - The category ID is invalid or does not exist
9631 /// - The HTTP request fails
9632 /// - The server returns an error status
9633 /// - The response cannot be parsed
9634 ///
9635 /// # Examples
9636 /// ```no_run
9637 /// # use amp_rs::ApiClient;
9638 /// # #[tokio::main]
9639 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9640 /// let client = ApiClient::new().await?;
9641 ///
9642 /// let category_id = 1;
9643 /// let category = client.get_category(category_id).await?;
9644 ///
9645 /// println!("Category: {} (ID: {})", category.name, category.id);
9646 /// if let Some(desc) = category.description {
9647 /// println!("Description: {}", desc);
9648 /// }
9649 /// println!("Users: {}, Assets: {}", category.registered_users.len(), category.assets.len());
9650 /// # Ok(())
9651 /// # }
9652 /// ```
9653 ///
9654 /// # Related Methods
9655 /// - [`get_categories`](Self::get_categories) - List all categories
9656 /// - [`add_category`](Self::add_category) - Create a new category
9657 /// - [`edit_category`](Self::edit_category) - Update category information
9658 /// - [`delete_category`](Self::delete_category) - Remove a category
9659 pub async fn get_category(&self, category_id: i64) -> Result<CategoryResponse, Error> {
9660 self.request_json(
9661 Method::GET,
9662 &["categories", &category_id.to_string()],
9663 None::<&()>,
9664 )
9665 .await
9666 }
9667
9668 /// Updates category information.
9669 ///
9670 /// This method allows you to modify the information of an existing category.
9671 /// Only the fields provided in the edit data will be updated; other fields remain unchanged.
9672 ///
9673 /// # Arguments
9674 /// * `category_id` - The ID of the category to update
9675 /// * `edit_category` - A `CategoryEdit` struct containing the fields to update
9676 ///
9677 /// # Returns
9678 /// Returns a `CategoryResponse` containing the updated category information.
9679 ///
9680 /// # Errors
9681 /// Returns an error if:
9682 /// - Authentication fails or insufficient permissions
9683 /// - The category ID is invalid or does not exist
9684 /// - The edit data contains invalid values (e.g., empty name, invalid characters)
9685 /// - A category with the new name already exists (if name is being changed)
9686 /// - The HTTP request fails
9687 /// - The server returns an error status
9688 /// - The response cannot be parsed
9689 ///
9690 /// # Examples
9691 /// ```no_run
9692 /// # use amp_rs::{ApiClient, model::CategoryEdit};
9693 /// # #[tokio::main]
9694 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9695 /// let client = ApiClient::new().await?;
9696 ///
9697 /// let category_id = 1;
9698 /// let edit_data = CategoryEdit {
9699 /// name: Some("VIP Users".to_string()),
9700 /// description: Some("Very important users with premium access".to_string()),
9701 /// };
9702 ///
9703 /// let updated_category = client.edit_category(category_id, &edit_data).await?;
9704 /// println!("Updated category: {}", updated_category.name);
9705 /// # Ok(())
9706 /// # }
9707 /// ```
9708 ///
9709 /// # Related Methods
9710 /// - [`get_category`](Self::get_category) - Get current category information
9711 /// - [`add_category`](Self::add_category) - Create a new category
9712 /// - [`delete_category`](Self::delete_category) - Remove a category
9713 pub async fn edit_category(
9714 &self,
9715 category_id: i64,
9716 edit_category: &CategoryEdit,
9717 ) -> Result<CategoryResponse, Error> {
9718 self.request_json(
9719 Method::PUT,
9720 &["categories", &category_id.to_string(), "edit"],
9721 Some(edit_category),
9722 )
9723 .await
9724 }
9725
9726 /// Removes a category from the system.
9727 ///
9728 /// This method permanently deletes a category. All users and assets associated with the
9729 /// category will be disassociated, but the users and assets themselves are not deleted.
9730 /// This operation cannot be undone.
9731 ///
9732 /// # Arguments
9733 /// * `category_id` - The ID of the category to delete
9734 ///
9735 /// # Returns
9736 /// Returns `Ok(())` on successful deletion.
9737 ///
9738 /// # Errors
9739 /// Returns an error if:
9740 /// - Authentication fails or insufficient permissions
9741 /// - The category ID is invalid or does not exist
9742 /// - The category is still in use and cannot be deleted (depending on system configuration)
9743 /// - The HTTP request fails
9744 /// - The server returns an error status
9745 ///
9746 /// # Examples
9747 /// ```no_run
9748 /// # use amp_rs::ApiClient;
9749 /// # #[tokio::main]
9750 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9751 /// let client = ApiClient::new().await?;
9752 ///
9753 /// let category_id = 1;
9754 /// client.delete_category(category_id).await?;
9755 /// println!("Successfully deleted category with ID {}", category_id);
9756 /// # Ok(())
9757 /// # }
9758 /// ```
9759 ///
9760 /// # Related Methods
9761 /// - [`get_category`](Self::get_category) - Get category information before deletion
9762 /// - [`add_category`](Self::add_category) - Create a new category
9763 /// - [`remove_registered_user_from_category`](Self::remove_registered_user_from_category) - Remove users first
9764 /// - [`remove_asset_from_category`](Self::remove_asset_from_category) - Remove assets first
9765 pub async fn delete_category(&self, category_id: i64) -> Result<(), Error> {
9766 self.request_empty(
9767 Method::DELETE,
9768 &["categories", &category_id.to_string(), "delete"],
9769 None::<&()>,
9770 )
9771 .await
9772 }
9773
9774 /// Associates a registered user with a category.
9775 ///
9776 /// This method adds a registered user to a category, allowing for organized grouping
9777 /// of users. Users can belong to multiple categories, and categories can contain
9778 /// multiple users.
9779 ///
9780 /// # Arguments
9781 /// * `category_id` - The ID of the category to add the user to
9782 /// * `user_id` - The ID of the registered user to add to the category
9783 ///
9784 /// # Returns
9785 /// Returns a `CategoryResponse` containing the updated category information including
9786 /// the newly added user.
9787 ///
9788 /// # Errors
9789 /// Returns an error if:
9790 /// - Authentication fails or insufficient permissions
9791 /// - The category ID is invalid or does not exist
9792 /// - The user ID is invalid or does not exist
9793 /// - The user is already associated with the category
9794 /// - The HTTP request fails
9795 /// - The server returns an error status
9796 /// - The response cannot be parsed
9797 ///
9798 /// # Examples
9799 /// ```no_run
9800 /// # use amp_rs::ApiClient;
9801 /// # #[tokio::main]
9802 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9803 /// let client = ApiClient::new().await?;
9804 ///
9805 /// let category_id = 1;
9806 /// let user_id = 123;
9807 ///
9808 /// let updated_category = client.add_registered_user_to_category(category_id, user_id).await?;
9809 /// println!("Added user {} to category '{}'", user_id, updated_category.name);
9810 /// println!("Category now has {} users", updated_category.registered_users.len());
9811 /// # Ok(())
9812 /// # }
9813 /// ```
9814 ///
9815 /// # Related Methods
9816 /// - [`remove_registered_user_from_category`](Self::remove_registered_user_from_category) - Remove user from category
9817 /// - [`get_category`](Self::get_category) - Get category information including users
9818 /// - [`get_registered_user`](Self::get_registered_user) - Get user information
9819 pub async fn add_registered_user_to_category(
9820 &self,
9821 category_id: i64,
9822 user_id: i64,
9823 ) -> Result<CategoryResponse, Error> {
9824 self.request_json(
9825 Method::PUT,
9826 &[
9827 "categories",
9828 &category_id.to_string(),
9829 "registered_users",
9830 &user_id.to_string(),
9831 "add",
9832 ],
9833 None::<&()>,
9834 )
9835 .await
9836 }
9837
9838 /// Removes a registered user from a category.
9839 ///
9840 /// This method disassociates a registered user from a category. The user remains in the
9841 /// system but is no longer part of the specified category. This does not affect the user's
9842 /// association with other categories.
9843 ///
9844 /// # Arguments
9845 /// * `category_id` - The ID of the category to remove the user from
9846 /// * `user_id` - The ID of the registered user to remove from the category
9847 ///
9848 /// # Returns
9849 /// Returns a `CategoryResponse` containing the updated category information without
9850 /// the removed user.
9851 ///
9852 /// # Errors
9853 /// Returns an error if:
9854 /// - Authentication fails or insufficient permissions
9855 /// - The category ID is invalid or does not exist
9856 /// - The user ID is invalid or does not exist
9857 /// - The user is not currently associated with the category
9858 /// - The HTTP request fails
9859 /// - The server returns an error status
9860 /// - The response cannot be parsed
9861 ///
9862 /// # Examples
9863 /// ```no_run
9864 /// # use amp_rs::ApiClient;
9865 /// # #[tokio::main]
9866 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9867 /// let client = ApiClient::new().await?;
9868 ///
9869 /// let category_id = 1;
9870 /// let user_id = 123;
9871 ///
9872 /// let updated_category = client.remove_registered_user_from_category(category_id, user_id).await?;
9873 /// println!("Removed user {} from category '{}'", user_id, updated_category.name);
9874 /// println!("Category now has {} users", updated_category.registered_users.len());
9875 /// # Ok(())
9876 /// # }
9877 /// ```
9878 ///
9879 /// # Related Methods
9880 /// - [`add_registered_user_to_category`](Self::add_registered_user_to_category) - Add user to category
9881 /// - [`get_category`](Self::get_category) - Get category information including users
9882 /// - [`get_registered_user`](Self::get_registered_user) - Get user information
9883 pub async fn remove_registered_user_from_category(
9884 &self,
9885 category_id: i64,
9886 user_id: i64,
9887 ) -> Result<CategoryResponse, Error> {
9888 self.request_json(
9889 Method::PUT,
9890 &[
9891 "categories",
9892 &category_id.to_string(),
9893 "registered_users",
9894 &user_id.to_string(),
9895 "remove",
9896 ],
9897 None::<&()>,
9898 )
9899 .await
9900 }
9901
9902 /// Associates an asset with a category.
9903 ///
9904 /// This method adds an asset to a category, allowing for organized grouping of assets.
9905 /// Assets can belong to multiple categories, and categories can contain multiple assets.
9906 /// This helps with asset management and permission organization.
9907 ///
9908 /// # Arguments
9909 /// * `category_id` - The ID of the category to add the asset to
9910 /// * `asset_uuid` - The UUID of the asset to add to the category
9911 ///
9912 /// # Returns
9913 /// Returns a `CategoryResponse` containing the updated category information including
9914 /// the newly added asset.
9915 ///
9916 /// # Errors
9917 /// Returns an error if:
9918 /// - Authentication fails or insufficient permissions
9919 /// - The category ID is invalid or does not exist
9920 /// - The asset UUID is invalid or does not exist
9921 /// - The asset is already associated with the category
9922 /// - The HTTP request fails
9923 /// - The server returns an error status
9924 /// - The response cannot be parsed
9925 ///
9926 /// # Examples
9927 /// ```no_run
9928 /// # use amp_rs::ApiClient;
9929 /// # #[tokio::main]
9930 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9931 /// let client = ApiClient::new().await?;
9932 ///
9933 /// let category_id = 1;
9934 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
9935 ///
9936 /// let updated_category = client.add_asset_to_category(category_id, asset_uuid).await?;
9937 /// println!("Added asset {} to category '{}'", asset_uuid, updated_category.name);
9938 /// println!("Category now has {} assets", updated_category.assets.len());
9939 /// # Ok(())
9940 /// # }
9941 /// ```
9942 ///
9943 /// # Related Methods
9944 /// - [`remove_asset_from_category`](Self::remove_asset_from_category) - Remove asset from category
9945 /// - [`get_category`](Self::get_category) - Get category information including assets
9946 /// - [`get_asset`](Self::get_asset) - Get asset information
9947 pub async fn add_asset_to_category(
9948 &self,
9949 category_id: i64,
9950 asset_uuid: &str,
9951 ) -> Result<CategoryResponse, Error> {
9952 self.request_json(
9953 Method::PUT,
9954 &[
9955 "categories",
9956 &category_id.to_string(),
9957 "assets",
9958 asset_uuid,
9959 "add",
9960 ],
9961 None::<&()>,
9962 )
9963 .await
9964 }
9965
9966 /// Removes an asset from a category.
9967 ///
9968 /// This method disassociates an asset from a category. The asset remains in the system
9969 /// but is no longer part of the specified category. This does not affect the asset's
9970 /// association with other categories.
9971 ///
9972 /// # Arguments
9973 /// * `category_id` - The ID of the category to remove the asset from
9974 /// * `asset_uuid` - The UUID of the asset to remove from the category
9975 ///
9976 /// # Returns
9977 /// Returns a `CategoryResponse` containing the updated category information without
9978 /// the removed asset.
9979 ///
9980 /// # Errors
9981 /// Returns an error if:
9982 /// - Authentication fails or insufficient permissions
9983 /// - The category ID is invalid or does not exist
9984 /// - The asset UUID is invalid or does not exist
9985 /// - The asset is not currently associated with the category
9986 /// - The HTTP request fails
9987 /// - The server returns an error status
9988 /// - The response cannot be parsed
9989 ///
9990 /// # Examples
9991 /// ```no_run
9992 /// # use amp_rs::ApiClient;
9993 /// # #[tokio::main]
9994 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9995 /// let client = ApiClient::new().await?;
9996 ///
9997 /// let category_id = 1;
9998 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
9999 ///
10000 /// let updated_category = client.remove_asset_from_category(category_id, asset_uuid).await?;
10001 /// println!("Removed asset {} from category '{}'", asset_uuid, updated_category.name);
10002 /// println!("Category now has {} assets", updated_category.assets.len());
10003 /// # Ok(())
10004 /// # }
10005 /// ```
10006 ///
10007 /// # Related Methods
10008 /// - [`add_asset_to_category`](Self::add_asset_to_category) - Add asset to category
10009 /// - [`get_category`](Self::get_category) - Get category information including assets
10010 /// - [`get_asset`](Self::get_asset) - Get asset information
10011 pub async fn remove_asset_from_category(
10012 &self,
10013 category_id: i64,
10014 asset_uuid: &str,
10015 ) -> Result<CategoryResponse, Error> {
10016 self.request_json(
10017 Method::PUT,
10018 &[
10019 "categories",
10020 &category_id.to_string(),
10021 "assets",
10022 asset_uuid,
10023 "remove",
10024 ],
10025 None::<&()>,
10026 )
10027 .await
10028 }
10029
10030 /// Validates a GAID (Green Address ID).
10031 ///
10032 /// # Arguments
10033 /// * `gaid` - The GAID string to validate
10034 ///
10035 /// # Returns
10036 /// Returns a `ValidateGaidResponse` indicating whether the GAID is valid
10037 ///
10038 /// # Errors
10039 /// Returns an error if:
10040 /// - Authentication fails
10041 /// - The HTTP request fails
10042 /// - The response cannot be parsed
10043 ///
10044 /// # Examples
10045 /// ```no_run
10046 /// # use amp_rs::ApiClient;
10047 /// # #[tokio::main]
10048 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10049 /// let client = ApiClient::new().await?;
10050 ///
10051 /// let gaid = "GAbYScu6jkWUND2jo3L4KJxyvo55d";
10052 /// let validation = client.validate_gaid(gaid).await?;
10053 ///
10054 /// if validation.is_valid {
10055 /// println!("GAID {} is valid", gaid);
10056 /// } else {
10057 /// println!("GAID {} is invalid: {:?}", gaid, validation.error);
10058 /// }
10059 /// # Ok(())
10060 /// # }
10061 /// ```
10062 pub async fn validate_gaid(
10063 &self,
10064 gaid: &str,
10065 ) -> Result<crate::model::ValidateGaidResponse, Error> {
10066 self.request_json(Method::GET, &["gaids", gaid, "validate"], None::<&()>)
10067 .await
10068 }
10069
10070 /// Gets the address associated with a GAID.
10071 ///
10072 /// # Arguments
10073 /// * `gaid` - The GAID to get the address for
10074 ///
10075 /// # Returns
10076 /// Returns an `AddressGaidResponse` containing the address
10077 ///
10078 /// # Errors
10079 /// Returns an error if:
10080 /// - The GAID is invalid
10081 /// - Authentication fails
10082 /// - The HTTP request fails
10083 /// - The response cannot be parsed
10084 ///
10085 /// # Examples
10086 /// ```no_run
10087 /// # use amp_rs::ApiClient;
10088 /// # #[tokio::main]
10089 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10090 /// let client = ApiClient::new().await?;
10091 ///
10092 /// let gaid = "GAbYScu6jkWUND2jo3L4KJxyvo55d";
10093 /// let address_response = client.get_gaid_address(gaid).await?;
10094 ///
10095 /// println!("Address for GAID {}: {}", gaid, address_response.address);
10096 /// # Ok(())
10097 /// # }
10098 /// ```
10099 pub async fn get_gaid_address(
10100 &self,
10101 gaid: &str,
10102 ) -> Result<crate::model::AddressGaidResponse, Error> {
10103 self.request_json(Method::GET, &["gaids", gaid, "address"], None::<&()>)
10104 .await
10105 }
10106
10107 /// Gets a list of all managers.
10108 ///
10109 /// # Returns
10110 /// Returns a vector of `Manager` objects
10111 ///
10112 /// # Errors
10113 /// Returns an error if:
10114 /// - Authentication fails
10115 /// - The HTTP request fails
10116 /// - The response cannot be parsed
10117 ///
10118 /// # Examples
10119 /// ```no_run
10120 /// # use amp_rs::ApiClient;
10121 /// # #[tokio::main]
10122 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10123 /// let client = ApiClient::new().await?;
10124 ///
10125 /// let managers = client.get_managers().await?;
10126 /// for manager in managers {
10127 /// println!("Manager: {} (ID: {})", manager.username, manager.id);
10128 /// }
10129 /// # Ok(())
10130 /// # }
10131 /// ```
10132 pub async fn get_managers(&self) -> Result<Vec<crate::model::Manager>, Error> {
10133 self.request_json(Method::GET, &["managers"], None::<&()>)
10134 .await
10135 }
10136
10137 /// Creates a new manager.
10138 ///
10139 /// # Arguments
10140 /// * `new_manager` - The manager creation request containing username and password
10141 ///
10142 /// # Returns
10143 /// Returns the created `Manager` object
10144 ///
10145 /// # Errors
10146 /// Returns an error if:
10147 /// - Authentication fails
10148 /// - The HTTP request fails
10149 /// - The manager creation request is invalid
10150 /// - The response cannot be parsed
10151 ///
10152 /// # Examples
10153 /// ```no_run
10154 /// # use amp_rs::{ApiClient, model::ManagerCreate};
10155 /// # #[tokio::main]
10156 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10157 /// let client = ApiClient::new().await?;
10158 ///
10159 /// let new_manager = ManagerCreate {
10160 /// username: "new_manager".to_string(),
10161 /// password: "secure_password".to_string(),
10162 /// };
10163 ///
10164 /// let manager = client.create_manager(&new_manager).await?;
10165 /// println!("Created manager: {} (ID: {})", manager.username, manager.id);
10166 /// # Ok(())
10167 /// # }
10168 /// ```
10169 pub async fn create_manager(
10170 &self,
10171 new_manager: &crate::model::ManagerCreate,
10172 ) -> Result<crate::model::Manager, Error> {
10173 self.request_json(Method::POST, &["managers", "create"], Some(new_manager))
10174 .await
10175 }
10176
10177 /// Gets all assignments for a specific asset.
10178 ///
10179 /// # Arguments
10180 /// * `asset_uuid` - The UUID of the asset to get assignments for
10181 ///
10182 /// # Returns
10183 /// Returns a vector of `Assignment` objects
10184 ///
10185 /// # Errors
10186 /// Returns an error if:
10187 /// - Authentication fails
10188 /// - The HTTP request fails
10189 /// - The asset UUID is invalid
10190 /// - The response cannot be parsed
10191 ///
10192 /// # Examples
10193 /// ```no_run
10194 /// # use amp_rs::ApiClient;
10195 /// # #[tokio::main]
10196 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10197 /// let client = ApiClient::new().await?;
10198 ///
10199 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10200 /// let assignments = client.get_asset_assignments(asset_uuid).await?;
10201 ///
10202 /// for assignment in assignments {
10203 /// println!("Assignment ID: {}, Amount: {}", assignment.id, assignment.amount);
10204 /// }
10205 /// # Ok(())
10206 /// # }
10207 /// ```
10208 pub async fn get_asset_assignments(&self, asset_uuid: &str) -> Result<Vec<Assignment>, Error> {
10209 self.request_json(
10210 Method::GET,
10211 &["assets", asset_uuid, "assignments"],
10212 None::<&()>,
10213 )
10214 .await
10215 }
10216
10217 /// Creates multiple asset assignments in batch.
10218 ///
10219 /// This method creates multiple asset assignments for the specified asset. Each assignment
10220 /// allocates a specific amount of the asset to a registered user. The assignments are
10221 /// created individually due to API limitations, but this method handles the batch processing
10222 /// automatically.
10223 ///
10224 /// # Arguments
10225 /// * `asset_uuid` - The UUID of the asset to create assignments for
10226 /// * `requests` - A slice of `CreateAssetAssignmentRequest` structs containing assignment details
10227 ///
10228 /// # Returns
10229 /// Returns a vector of `Assignment` structs representing the created assignments with their
10230 /// assigned IDs and status information.
10231 ///
10232 /// # Errors
10233 /// Returns an error if:
10234 /// - Authentication fails or insufficient permissions
10235 /// - The asset UUID is invalid or does not exist
10236 /// - Any assignment request contains invalid data (e.g., invalid user ID, negative amount)
10237 /// - Insufficient asset balance for the total requested assignments
10238 /// - Any individual assignment creation fails
10239 /// - The HTTP request fails
10240 /// - The server returns an error status
10241 /// - The response cannot be parsed
10242 ///
10243 /// # Examples
10244 /// ```no_run
10245 /// # use amp_rs::{ApiClient, model::CreateAssetAssignmentRequest};
10246 /// # #[tokio::main]
10247 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10248 /// let client = ApiClient::new().await?;
10249 ///
10250 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10251 /// let requests = vec![
10252 /// CreateAssetAssignmentRequest {
10253 /// registered_user: 123,
10254 /// amount: 1000,
10255 /// vesting_timestamp: None,
10256 /// ready_for_distribution: false,
10257 /// },
10258 /// CreateAssetAssignmentRequest {
10259 /// registered_user: 456,
10260 /// amount: 500,
10261 /// vesting_timestamp: None,
10262 /// ready_for_distribution: true,
10263 /// },
10264 /// ];
10265 ///
10266 /// let assignments = client.create_asset_assignments(asset_uuid, &requests).await?;
10267 /// println!("Created {} assignments", assignments.len());
10268 /// for assignment in assignments {
10269 /// println!("Assignment {}: {} units to user {}",
10270 /// assignment.id, assignment.amount, assignment.registered_user);
10271 /// }
10272 /// # Ok(())
10273 /// # }
10274 /// ```
10275 ///
10276 /// # Related Methods
10277 /// - [`get_asset_assignments`](Self::get_asset_assignments) - List all assignments for an asset
10278 /// - [`delete_asset_assignment`](Self::delete_asset_assignment) - Remove an assignment
10279 /// - [`edit_asset_assignment`](Self::edit_asset_assignment) - Update assignment details
10280 /// - [`set_assignment_ready_for_distribution`](Self::set_assignment_ready_for_distribution) - Mark for distribution
10281 pub async fn create_asset_assignments(
10282 &self,
10283 asset_uuid: &str,
10284 requests: &[CreateAssetAssignmentRequest],
10285 ) -> Result<Vec<Assignment>, Error> {
10286 use crate::model::CreateAssetAssignmentRequestWrapper;
10287
10288 // The API only supports maximum length 1 per request, so we need to break
10289 // multiple assignments into separate CreateAssetAssignmentRequestWrapper instances
10290 let mut all_assignments = Vec::new();
10291
10292 for request in requests {
10293 let wrapper = CreateAssetAssignmentRequestWrapper {
10294 assignments: vec![request.clone()],
10295 };
10296
10297 let assignments: Vec<Assignment> = self
10298 .request_json(
10299 Method::POST,
10300 &["assets", asset_uuid, "assignments", "create"],
10301 Some(&wrapper),
10302 )
10303 .await?;
10304
10305 all_assignments.extend(assignments);
10306 }
10307
10308 Ok(all_assignments)
10309 }
10310
10311 /// Gets a specific asset assignment by asset UUID and assignment ID.
10312 ///
10313 /// This method sends a GET request to retrieve detailed information about a specific asset
10314 /// assignment. Asset assignments represent the allocation of assets to users or entities,
10315 /// including information such as the assigned amount, recipient details, and assignment status.
10316 ///
10317 /// # Arguments
10318 /// * `asset_uuid` - The UUID of the asset for which to retrieve the assignment
10319 /// * `assignment_id` - The ID of the specific assignment to retrieve
10320 ///
10321 /// # Returns
10322 /// Returns an `Assignment` struct containing the assignment details including:
10323 /// - Assignment ID and amount
10324 /// - Recipient information
10325 /// - Assignment status and metadata
10326 /// - Creation and modification timestamps
10327 ///
10328 /// # Errors
10329 /// Returns an error if:
10330 /// - Authentication fails
10331 /// - The HTTP request fails
10332 /// - The server returns an error status
10333 /// - The asset UUID is invalid or does not exist
10334 /// - The assignment ID is invalid or does not exist
10335 /// - The assignment is not accessible to the current user
10336 /// - The response cannot be parsed as a valid Assignment
10337 ///
10338 /// # Example
10339 /// ```no_run
10340 /// # use amp_rs::ApiClient;
10341 /// # #[tokio::main]
10342 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10343 /// let client = ApiClient::new().await?;
10344 ///
10345 /// // Retrieve assignment with ID "123" for asset "550e8400-e29b-41d4-a716-446655440000"
10346 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10347 /// let assignment_id = "123";
10348 ///
10349 /// let assignment = client.get_asset_assignment(asset_uuid, assignment_id).await?;
10350 ///
10351 /// println!("Assignment ID: {}", assignment.id);
10352 /// println!("Assigned amount: {}", assignment.amount);
10353 /// println!("Registered user: {}", assignment.registered_user);
10354 /// # Ok(())
10355 /// # }
10356 /// ```
10357 pub async fn get_asset_assignment(
10358 &self,
10359 asset_uuid: &str,
10360 assignment_id: &str,
10361 ) -> Result<Assignment, Error> {
10362 self.request_json(
10363 Method::GET,
10364 &["assets", asset_uuid, "assignments", assignment_id],
10365 None::<&()>,
10366 )
10367 .await
10368 }
10369
10370 /// Creates a distribution for an asset with the specified assignments.
10371 ///
10372 /// This method initiates the distribution creation process by sending assignment details
10373 /// to the AMP API. The API will return a distribution UUID and address mappings that
10374 /// can be used for subsequent transaction creation and confirmation steps.
10375 ///
10376 /// # Arguments
10377 /// * `asset_uuid` - The UUID of the asset to distribute
10378 /// * `assignments` - A vector of `AssetDistributionAssignment` structs containing user IDs, addresses, and amounts
10379 ///
10380 /// # Returns
10381 /// Returns a `DistributionResponse` containing:
10382 /// - `distribution_uuid` - Unique identifier for the created distribution
10383 /// - `map_address_amount` - Mapping of addresses to amounts to be distributed
10384 /// - `map_address_asset` - Mapping of addresses to asset IDs
10385 /// - `asset_id` - The asset ID for the distribution
10386 ///
10387 /// # Errors
10388 /// Returns an `AmpError` if:
10389 /// - Authentication fails or insufficient permissions
10390 /// - The asset UUID is invalid or does not exist
10391 /// - Assignment data is invalid (e.g., invalid user IDs, negative amounts, invalid addresses)
10392 /// - Insufficient asset balance for the requested distribution
10393 /// - The HTTP request fails
10394 /// - The server returns an error status
10395 /// - The response cannot be parsed
10396 ///
10397 /// # Examples
10398 /// ```no_run
10399 /// # use amp_rs::{ApiClient, model::AssetDistributionAssignment, AmpError};
10400 /// # #[tokio::main]
10401 /// # async fn main() -> Result<(), AmpError> {
10402 /// let client = ApiClient::new().await.map_err(AmpError::from)?;
10403 ///
10404 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10405 /// let assignments = vec![
10406 /// AssetDistributionAssignment {
10407 /// user_id: "user123".to_string(),
10408 /// address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
10409 /// amount: 100.0,
10410 /// },
10411 /// AssetDistributionAssignment {
10412 /// user_id: "user456".to_string(),
10413 /// address: "lq1qq3xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
10414 /// amount: 50.0,
10415 /// },
10416 /// ];
10417 ///
10418 /// let distribution_response = client.create_distribution(asset_uuid, assignments).await?;
10419 /// println!("Created distribution: {}", distribution_response.distribution_uuid);
10420 /// println!("Asset ID: {}", distribution_response.asset_id);
10421 /// # Ok(())
10422 /// # }
10423 /// ```
10424 ///
10425 /// # Related Methods
10426 /// - [`get_asset_assignments`](Self::get_asset_assignments) - List assignments for an asset
10427 /// - [`create_asset_assignments`](Self::create_asset_assignments) - Create new assignments
10428 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
10429 pub async fn create_distribution(
10430 &self,
10431 asset_uuid: &str,
10432 assignments: Vec<crate::model::AssetDistributionAssignment>,
10433 ) -> Result<crate::model::DistributionResponse, AmpError> {
10434 use crate::model::{CreateDistributionRequest, DistributionAssignmentRequest};
10435
10436 let create_span = tracing::debug_span!(
10437 "create_distribution",
10438 asset_uuid = %asset_uuid,
10439 assignment_count = assignments.len()
10440 );
10441 let _enter = create_span.enter();
10442
10443 tracing::debug!(
10444 "Creating distribution for asset {} with {} assignments",
10445 asset_uuid,
10446 assignments.len()
10447 );
10448
10449 // Validate inputs
10450 if asset_uuid.is_empty() {
10451 tracing::error!("Distribution creation failed: empty asset UUID");
10452 return Err(AmpError::validation("Asset UUID cannot be empty"));
10453 }
10454
10455 if assignments.is_empty() {
10456 tracing::error!("Distribution creation failed: empty assignments");
10457 return Err(AmpError::validation("Assignments cannot be empty"));
10458 }
10459
10460 // Convert AssetDistributionAssignment to DistributionAssignmentRequest
10461 // The API expects user_uuid field, but our input uses user_id
10462 tracing::trace!("Converting {} assignments to API format", assignments.len());
10463 let mut total_amount = 0.0;
10464 let api_assignments: Vec<DistributionAssignmentRequest> = assignments
10465 .into_iter()
10466 .enumerate()
10467 .map(
10468 #[allow(clippy::cognitive_complexity)]
10469 |(index, assignment)| {
10470 tracing::trace!(
10471 "Converting assignment {}: user_id={}, address={}, amount={}",
10472 index,
10473 assignment.user_id,
10474 assignment.address,
10475 assignment.amount
10476 );
10477
10478 // Validate assignment data
10479 if assignment.user_id.is_empty() {
10480 tracing::error!("Assignment {} has empty user_id", index);
10481 return Err(AmpError::validation(format!(
10482 "Assignment {index} has empty user_id"
10483 )));
10484 }
10485 if assignment.address.is_empty() {
10486 tracing::error!("Assignment {} has empty address", index);
10487 return Err(AmpError::validation(format!(
10488 "Assignment {index} has empty address"
10489 )));
10490 }
10491 if assignment.amount <= 0.0 {
10492 tracing::error!(
10493 "Assignment {} has non-positive amount: {}",
10494 index,
10495 assignment.amount
10496 );
10497 return Err(AmpError::validation(format!(
10498 "Assignment {} has non-positive amount: {}",
10499 index, assignment.amount
10500 )));
10501 }
10502
10503 total_amount += assignment.amount;
10504
10505 Ok(DistributionAssignmentRequest {
10506 user_uuid: assignment.user_id, // Map user_id to user_uuid for API
10507 amount: assignment.amount,
10508 address: assignment.address,
10509 })
10510 },
10511 )
10512 .collect::<Result<Vec<_>, AmpError>>()?;
10513
10514 tracing::debug!(
10515 "Converted {} assignments successfully, total amount: {}",
10516 api_assignments.len(),
10517 total_amount
10518 );
10519
10520 let request = CreateDistributionRequest {
10521 assignments: api_assignments,
10522 };
10523
10524 tracing::debug!("Sending distribution creation request to AMP API");
10525 let api_call_start = std::time::Instant::now();
10526
10527 // Make the API call
10528 let response: crate::model::DistributionResponse = self
10529 .request_json(
10530 Method::GET,
10531 &["assets", asset_uuid, "distributions", "create"],
10532 Some(&request),
10533 )
10534 .await
10535 .map_err(
10536 #[allow(clippy::cognitive_complexity)]
10537 |e| {
10538 let api_call_duration = api_call_start.elapsed();
10539 let error_msg =
10540 format!("Failed to create distribution after {api_call_duration:?}: {e}");
10541 tracing::error!("{}", error_msg);
10542
10543 // Check for specific API error patterns
10544 let error_str = e.to_string();
10545 if error_str.contains("404") || error_str.contains("not found") {
10546 tracing::error!(
10547 "Asset {} not found - verify asset UUID is correct",
10548 asset_uuid
10549 );
10550 } else if error_str.contains("400") || error_str.contains("bad request") {
10551 tracing::error!("Bad request - check assignment data format and values");
10552 } else if error_str.contains("401") || error_str.contains("unauthorized") {
10553 tracing::error!("Unauthorized - check API credentials and token validity");
10554 } else if error_str.contains("403") || error_str.contains("forbidden") {
10555 tracing::error!("Forbidden - check permissions for asset distribution");
10556 } else if error_str.contains("429") || error_str.contains("rate limit") {
10557 tracing::error!("Rate limited - wait before retrying");
10558 } else if error_str.contains("500") || error_str.contains("internal server") {
10559 tracing::error!(
10560 "Server error - this may be a temporary issue, retry may help"
10561 );
10562 }
10563
10564 AmpError::api(error_msg)
10565 },
10566 )?;
10567
10568 let api_call_duration = api_call_start.elapsed();
10569 tracing::info!(
10570 "Successfully created distribution: {} (took {:?})",
10571 response.distribution_uuid,
10572 api_call_duration
10573 );
10574
10575 // Validate response data
10576 if response.distribution_uuid.is_empty() {
10577 tracing::error!("API returned empty distribution UUID");
10578 return Err(AmpError::api("API returned empty distribution UUID"));
10579 }
10580
10581 if response.asset_id.is_empty() {
10582 tracing::error!("API returned empty asset ID");
10583 return Err(AmpError::api("API returned empty asset ID"));
10584 }
10585
10586 if response.map_address_amount.is_empty() {
10587 tracing::error!("API returned empty address mapping");
10588 return Err(AmpError::api("API returned empty address mapping"));
10589 }
10590
10591 tracing::debug!(
10592 "Distribution response validated - {} addresses mapped, asset_id: {}",
10593 response.map_address_amount.len(),
10594 response.asset_id
10595 );
10596
10597 Ok(response)
10598 }
10599
10600 /// Confirms a distribution with transaction and change data.
10601 ///
10602 /// This method submits the final confirmation for a distribution by providing
10603 /// the transaction details and any change UTXOs to the AMP API. This completes
10604 /// the distribution workflow after the transaction has been broadcast and confirmed
10605 /// on the blockchain.
10606 ///
10607 /// # Arguments
10608 /// * `asset_uuid` - The UUID of the asset being distributed
10609 /// * `distribution_uuid` - The UUID of the distribution to confirm (from `create_distribution` response)
10610 /// * `tx_data` - Transaction data containing details and txid from the blockchain
10611 /// * `change_data` - Vector of change UTXOs from the transaction
10612 ///
10613 /// # Errors
10614 /// Returns an error if:
10615 /// - Authentication fails
10616 /// - The asset UUID or distribution UUID is invalid
10617 /// - The transaction data is invalid or incomplete
10618 /// - The HTTP request fails
10619 /// - The server returns an error status
10620 /// - The response cannot be parsed
10621 ///
10622 /// # Examples
10623 /// ```no_run
10624 /// # use amp_rs::{ApiClient, model::{AmpTxData, Unspent}, AmpError};
10625 /// # #[tokio::main]
10626 /// # async fn main() -> Result<(), AmpError> {
10627 /// # let client = ApiClient::new().await?;
10628 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10629 /// let distribution_uuid = "dist-550e8400-e29b-41d4-a716-446655440000";
10630 ///
10631 /// // Transaction data for AMP API confirmation
10632 /// let tx_data = AmpTxData {
10633 /// details: serde_json::json!([{
10634 /// "account": "",
10635 /// "address": "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
10636 /// "category": "send",
10637 /// "amount": -100.0,
10638 /// "vout": 0,
10639 /// "fee": -0.001
10640 /// }]),
10641 /// txid: "abc123def456...".to_string(),
10642 /// };
10643 ///
10644 /// // Change UTXOs from Elements node listunspent call
10645 /// let change_data = vec![
10646 /// Unspent {
10647 /// txid: "abc123def456...".to_string(),
10648 /// vout: 1,
10649 /// amount: 25.0,
10650 /// asset: "asset_id_hex".to_string(),
10651 /// address: "change_address".to_string(),
10652 /// spendable: true,
10653 /// confirmations: Some(2),
10654 /// scriptpubkey: Some("76a914...88ac".to_string()),
10655 /// redeemscript: None,
10656 /// witnessscript: None,
10657 /// amountblinder: None,
10658 /// assetblinder: None,
10659 /// }
10660 /// ];
10661 ///
10662 /// client.confirm_distribution(asset_uuid, distribution_uuid, tx_data, change_data).await?;
10663 /// println!("Distribution confirmed successfully");
10664 /// # Ok(())
10665 /// # }
10666 /// ```
10667 ///
10668 /// # Related Methods
10669 /// - [`create_distribution`](Self::create_distribution) - Create a new distribution
10670 /// - [`get_asset_assignments`](Self::get_asset_assignments) - List assignments for an asset
10671 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
10672 pub async fn confirm_distribution(
10673 &self,
10674 asset_uuid: &str,
10675 distribution_uuid: &str,
10676 tx_data: crate::model::AmpTxData,
10677 change_data: Vec<crate::model::Unspent>,
10678 ) -> Result<(), AmpError> {
10679 use crate::model::ConfirmDistributionRequest;
10680
10681 let confirm_span = tracing::debug_span!(
10682 "confirm_distribution",
10683 asset_uuid = %asset_uuid,
10684 distribution_uuid = %distribution_uuid,
10685 txid = %tx_data.txid,
10686 change_count = change_data.len()
10687 );
10688 let _enter = confirm_span.enter();
10689
10690 tracing::debug!(
10691 "Confirming distribution {} for asset {} with txid {} ({} change UTXOs)",
10692 distribution_uuid,
10693 asset_uuid,
10694 tx_data.txid,
10695 change_data.len()
10696 );
10697
10698 // Validate inputs
10699 if asset_uuid.is_empty() {
10700 tracing::error!("Distribution confirmation failed: empty asset UUID");
10701 return Err(AmpError::validation("Asset UUID cannot be empty"));
10702 }
10703
10704 if distribution_uuid.is_empty() {
10705 tracing::error!("Distribution confirmation failed: empty distribution UUID");
10706 return Err(AmpError::validation("Distribution UUID cannot be empty"));
10707 }
10708
10709 if tx_data.txid.is_empty() {
10710 tracing::error!("Distribution confirmation failed: empty transaction ID");
10711 return Err(AmpError::validation("Transaction ID cannot be empty"));
10712 }
10713
10714 // Log transaction details for debugging
10715 tracing::debug!("Transaction details array: {:?}", tx_data.details);
10716
10717 // Log change data details
10718 if change_data.is_empty() {
10719 tracing::debug!("No change UTXOs to include in confirmation");
10720 } else {
10721 let total_change: f64 = change_data.iter().map(|utxo| utxo.amount).sum();
10722 tracing::debug!(
10723 "Change data - {} UTXOs, total amount: {}",
10724 change_data.len(),
10725 total_change
10726 );
10727
10728 for (i, utxo) in change_data.iter().enumerate() {
10729 tracing::trace!(
10730 "Change UTXO {}: txid={}, vout={}, amount={}, spendable={}",
10731 i,
10732 utxo.txid,
10733 utxo.vout,
10734 utxo.amount,
10735 utxo.spendable
10736 );
10737 }
10738 }
10739
10740 let request = ConfirmDistributionRequest {
10741 tx_data: tx_data.clone(),
10742 change_data: change_data.clone(),
10743 };
10744
10745 tracing::debug!("Sending distribution confirmation request to AMP API");
10746 let api_call_start = std::time::Instant::now();
10747
10748 // Make the API call
10749 self.request_empty(
10750 Method::POST,
10751 &["assets", asset_uuid, "distributions", distribution_uuid, "confirm"],
10752 Some(&request),
10753 )
10754 .await
10755 .map_err(#[allow(clippy::cognitive_complexity)] |e| {
10756 let api_call_duration = api_call_start.elapsed();
10757 let error_msg = format!(
10758 "Failed to confirm distribution {} after {:?}: {}. IMPORTANT: Transaction {} was successful on blockchain. Use this txid to manually retry confirmation.",
10759 distribution_uuid, api_call_duration, e, tx_data.txid
10760 );
10761 tracing::error!("{}", error_msg);
10762
10763 // Check for specific API error patterns
10764 let error_str = e.to_string();
10765 if error_str.contains("404") || error_str.contains("not found") {
10766 tracing::error!("Distribution {} not found - verify distribution UUID is correct", distribution_uuid);
10767 } else if error_str.contains("400") || error_str.contains("bad request") {
10768 tracing::error!("Bad request - check transaction data format and change data");
10769 } else if error_str.contains("409") || error_str.contains("conflict") {
10770 tracing::error!("Conflict - distribution may already be confirmed");
10771 } else if error_str.contains("422") || error_str.contains("unprocessable") {
10772 tracing::error!("Unprocessable entity - check transaction confirmations and data validity");
10773 } else if error_str.contains("500") || error_str.contains("internal server") {
10774 tracing::error!("Server error - this may be a temporary issue, retry with txid: {}", tx_data.txid);
10775 }
10776
10777 AmpError::api(error_msg)
10778 })?;
10779
10780 let api_call_duration = api_call_start.elapsed();
10781 tracing::info!(
10782 "Successfully confirmed distribution: {} for asset: {} with txid: {} (took {:?})",
10783 distribution_uuid,
10784 asset_uuid,
10785 tx_data.txid,
10786 api_call_duration
10787 );
10788
10789 Ok(())
10790 }
10791
10792 /// Cancels an in-progress distribution for an asset.
10793 ///
10794 /// This method cancels a distribution that is currently in progress (unconfirmed status).
10795 /// Once a distribution is cancelled, it cannot be confirmed and the assigned amounts
10796 /// become available for new distributions.
10797 ///
10798 /// # Arguments
10799 /// * `asset_uuid` - The UUID of the asset
10800 /// * `distribution_uuid` - The UUID of the distribution to cancel
10801 ///
10802 /// # Returns
10803 /// Returns `Ok(())` if the distribution was successfully cancelled.
10804 ///
10805 /// # Errors
10806 /// Returns an error if:
10807 /// - Authentication fails
10808 /// - The HTTP request fails
10809 /// - The server returns an error status
10810 /// - The distribution is not found
10811 /// - The distribution is already confirmed and cannot be cancelled
10812 ///
10813 /// # Examples
10814 /// ```no_run
10815 /// use amp_rs::ApiClient;
10816 ///
10817 /// #[tokio::main]
10818 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
10819 /// let client = ApiClient::new().await?;
10820 ///
10821 /// client.cancel_distribution(
10822 /// "asset-uuid-123",
10823 /// "distribution-uuid-456"
10824 /// ).await?;
10825 ///
10826 /// println!("Distribution cancelled successfully");
10827 /// Ok(())
10828 /// # }
10829 /// ```
10830 #[allow(clippy::cognitive_complexity)]
10831 pub async fn cancel_distribution(
10832 &self,
10833 asset_uuid: &str,
10834 distribution_uuid: &str,
10835 ) -> Result<(), AmpError> {
10836 let cancel_span = tracing::debug_span!(
10837 "cancel_distribution",
10838 asset_uuid = %asset_uuid,
10839 distribution_uuid = %distribution_uuid
10840 );
10841 let _enter = cancel_span.enter();
10842
10843 tracing::debug!(
10844 "Cancelling distribution {} for asset {}",
10845 distribution_uuid,
10846 asset_uuid
10847 );
10848
10849 // Validate inputs
10850 if asset_uuid.is_empty() {
10851 tracing::error!("Distribution cancellation failed: empty asset UUID");
10852 return Err(AmpError::validation("Asset UUID cannot be empty"));
10853 }
10854
10855 if distribution_uuid.is_empty() {
10856 tracing::error!("Distribution cancellation failed: empty distribution UUID");
10857 return Err(AmpError::validation("Distribution UUID cannot be empty"));
10858 }
10859
10860 let api_call_start = std::time::Instant::now();
10861
10862 self.request_empty(
10863 Method::DELETE,
10864 &[
10865 "assets",
10866 asset_uuid,
10867 "distributions",
10868 distribution_uuid,
10869 "cancel",
10870 ],
10871 None::<&()>,
10872 )
10873 .await
10874 .map_err(|e| {
10875 let api_call_duration = api_call_start.elapsed();
10876 let error_msg = format!(
10877 "Failed to cancel distribution {distribution_uuid} for asset {asset_uuid} after {api_call_duration:?}: {e}"
10878 );
10879 tracing::error!("{}", error_msg);
10880
10881 // Check for specific API error patterns
10882 let error_str = e.to_string();
10883 if error_str.contains("404") || error_str.contains("not found") {
10884 tracing::error!(
10885 "Distribution {} not found - verify distribution UUID is correct",
10886 distribution_uuid
10887 );
10888 } else if error_str.contains("400") || error_str.contains("bad request") {
10889 tracing::error!("Bad request - distribution may already be confirmed or invalid");
10890 } else if error_str.contains("409") || error_str.contains("conflict") {
10891 tracing::error!(
10892 "Conflict - distribution may already be confirmed and cannot be cancelled"
10893 );
10894 } else if error_str.contains("422") || error_str.contains("unprocessable") {
10895 tracing::error!(
10896 "Unprocessable entity - distribution is in a state that cannot be cancelled"
10897 );
10898 }
10899
10900 AmpError::api(error_msg)
10901 })?;
10902
10903 let api_call_duration = api_call_start.elapsed();
10904 tracing::info!(
10905 "Successfully cancelled distribution: {} for asset: {} (took {:?})",
10906 distribution_uuid,
10907 asset_uuid,
10908 api_call_duration
10909 );
10910
10911 Ok(())
10912 }
10913
10914 /// Gets all distributions for a specific asset.
10915 ///
10916 /// This method retrieves all distributions (both confirmed and unconfirmed) for the specified asset.
10917 /// This is useful for checking if there are any in-progress distributions before deleting an asset.
10918 ///
10919 /// # Arguments
10920 /// * `asset_uuid` - The UUID of the asset to get distributions for
10921 ///
10922 /// # Returns
10923 /// Returns a vector of `Distribution` objects for the asset.
10924 ///
10925 /// # Errors
10926 /// Returns an error if:
10927 /// - Authentication fails
10928 /// - The HTTP request fails
10929 /// - The server returns an error status
10930 /// - The response cannot be parsed
10931 ///
10932 /// # Examples
10933 /// ```no_run
10934 /// use amp_rs::ApiClient;
10935 ///
10936 /// #[tokio::main]
10937 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
10938 /// let client = ApiClient::new().await?;
10939 ///
10940 /// let distributions = client.get_asset_distributions("asset-uuid-123").await?;
10941 ///
10942 /// for distribution in distributions {
10943 /// println!("Distribution: {} - Status: {:?}",
10944 /// distribution.distribution_uuid,
10945 /// distribution.distribution_status);
10946 /// }
10947 /// Ok(())
10948 /// }
10949 /// ```
10950 pub async fn get_asset_distributions(
10951 &self,
10952 asset_uuid: &str,
10953 ) -> Result<Vec<crate::model::Distribution>, Error> {
10954 let distributions_span = tracing::debug_span!(
10955 "get_asset_distributions",
10956 asset_uuid = %asset_uuid
10957 );
10958 let _enter = distributions_span.enter();
10959
10960 tracing::debug!("Getting distributions for asset {}", asset_uuid);
10961
10962 // Validate input
10963 if asset_uuid.is_empty() {
10964 tracing::error!("Get distributions failed: empty asset UUID");
10965 return Err(Error::RequestFailed(
10966 "Asset UUID cannot be empty".to_string(),
10967 ));
10968 }
10969
10970 self.request_json(
10971 Method::GET,
10972 &["assets", asset_uuid, "distributions"],
10973 None::<&()>,
10974 )
10975 .await
10976 }
10977
10978 /// Gets a specific distribution by UUID for an asset.
10979 ///
10980 /// This method retrieves detailed information about a specific distribution,
10981 /// including its status, UUID, and associated transactions.
10982 ///
10983 /// # Arguments
10984 /// * `asset_uuid` - The UUID of the asset
10985 /// * `distribution_uuid` - The UUID of the distribution to retrieve
10986 ///
10987 /// # Returns
10988 /// Returns a `Distribution` struct containing:
10989 /// - `distribution_uuid` - The unique identifier for the distribution
10990 /// - `distribution_status` - Current status of the distribution
10991 /// - `transactions` - List of transactions associated with the distribution
10992 ///
10993 /// # Errors
10994 /// Returns an error if:
10995 /// - Authentication fails
10996 /// - The HTTP request fails
10997 /// - The server returns an error status
10998 /// - The response cannot be parsed as JSON
10999 /// - The asset UUID or distribution UUID is empty
11000 ///
11001 /// # Examples
11002 /// ```no_run
11003 /// # use amp_rs::ApiClient;
11004 /// # #[tokio::main]
11005 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
11006 /// let client = ApiClient::new().await?;
11007 ///
11008 /// let distribution = client.get_asset_distribution(
11009 /// "asset-uuid-123",
11010 /// "distribution-uuid-456"
11011 /// ).await?;
11012 ///
11013 /// println!("Distribution: {} - Status: {:?}",
11014 /// distribution.distribution_uuid,
11015 /// distribution.distribution_status);
11016 /// # Ok(())
11017 /// # }
11018 /// ```
11019 ///
11020 /// # Related Methods
11021 /// - [`get_asset_distributions`](Self::get_asset_distributions) - List all distributions for an asset
11022 /// - [`create_distribution`](Self::create_distribution) - Create a new distribution
11023 /// - [`confirm_distribution`](Self::confirm_distribution) - Confirm a distribution
11024 /// - [`cancel_distribution`](Self::cancel_distribution) - Cancel a distribution
11025 #[allow(clippy::cognitive_complexity)]
11026 pub async fn get_asset_distribution(
11027 &self,
11028 asset_uuid: &str,
11029 distribution_uuid: &str,
11030 ) -> Result<crate::model::Distribution, Error> {
11031 let distribution_span = tracing::debug_span!(
11032 "get_asset_distribution",
11033 asset_uuid = %asset_uuid,
11034 distribution_uuid = %distribution_uuid
11035 );
11036 let _enter = distribution_span.enter();
11037
11038 tracing::debug!(
11039 "Getting distribution {} for asset {}",
11040 distribution_uuid,
11041 asset_uuid
11042 );
11043
11044 // Validate inputs
11045 if asset_uuid.is_empty() {
11046 tracing::error!("Get distribution failed: empty asset UUID");
11047 return Err(Error::RequestFailed(
11048 "Asset UUID cannot be empty".to_string(),
11049 ));
11050 }
11051
11052 if distribution_uuid.is_empty() {
11053 tracing::error!("Get distribution failed: empty distribution UUID");
11054 return Err(Error::RequestFailed(
11055 "Distribution UUID cannot be empty".to_string(),
11056 ));
11057 }
11058
11059 self.request_json(
11060 Method::GET,
11061 &["assets", asset_uuid, "distributions", distribution_uuid],
11062 None::<&()>,
11063 )
11064 .await
11065 }
11066
11067 /// Gets a specific manager by ID.
11068 ///
11069 /// # Arguments
11070 /// * `manager_id` - The ID of the manager to retrieve
11071 ///
11072 /// # Errors
11073 /// Returns an error if:
11074 /// - Authentication fails
11075 /// - The HTTP request fails
11076 /// - The server returns an error status
11077 /// - The response cannot be parsed as JSON
11078 pub async fn get_manager(&self, manager_id: i64) -> Result<crate::model::Manager, Error> {
11079 self.request_json(
11080 Method::GET,
11081 &["managers", &manager_id.to_string()],
11082 None::<&()>,
11083 )
11084 .await
11085 }
11086
11087 /// Removes a manager's permissions to modify a specific asset.
11088 ///
11089 /// This method revokes a manager's access to a specific asset, preventing them from
11090 /// performing asset management operations such as creating assignments, managing ownership,
11091 /// or modifying asset properties. The manager will no longer be able to access this asset
11092 /// through their management interface.
11093 ///
11094 /// # Arguments
11095 /// * `manager_id` - The ID of the manager to remove permissions from
11096 /// * `asset_uuid` - The UUID of the asset to remove permissions for
11097 ///
11098 /// # Returns
11099 /// Returns `Ok(())` on successful permission removal.
11100 ///
11101 /// # Errors
11102 /// Returns an error if:
11103 /// - Authentication fails or insufficient permissions
11104 /// - The manager ID is invalid or does not exist
11105 /// - The asset UUID is invalid or does not exist
11106 /// - The manager does not currently have permissions for this asset
11107 /// - The HTTP request fails
11108 /// - The server returns an error status
11109 ///
11110 /// # Examples
11111 /// ```no_run
11112 /// # use amp_rs::ApiClient;
11113 /// # #[tokio::main]
11114 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
11115 /// let client = ApiClient::new().await?;
11116 ///
11117 /// let manager_id = 123;
11118 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
11119 ///
11120 /// client.manager_remove_asset(manager_id, asset_uuid).await?;
11121 /// println!("Removed asset {} from manager {}", asset_uuid, manager_id);
11122 /// # Ok(())
11123 /// # }
11124 /// ```
11125 ///
11126 /// # Related Methods
11127 /// - [`add_asset_to_manager`](Self::add_asset_to_manager) - Grant manager permissions for an asset
11128 /// - [`get_manager`](Self::get_manager) - Get manager information including current assets
11129 /// - [`revoke_manager`](Self::revoke_manager) - Remove all asset permissions from manager
11130 /// - [`lock_manager`](Self::lock_manager) - Lock manager account
11131 pub async fn manager_remove_asset(
11132 &self,
11133 manager_id: i64,
11134 asset_uuid: &str,
11135 ) -> Result<(), Error> {
11136 self.request_empty(
11137 Method::POST,
11138 &[
11139 "managers",
11140 &manager_id.to_string(),
11141 "assets",
11142 asset_uuid,
11143 "remove",
11144 ],
11145 None::<&()>,
11146 )
11147 .await
11148 }
11149
11150 /// Revokes all asset permissions for a manager.
11151 ///
11152 /// This method first retrieves the manager's current asset permissions,
11153 /// then removes the manager's access to each asset they currently have access to.
11154 ///
11155 /// # Arguments
11156 /// * `manager_id` - The ID of the manager to revoke permissions for
11157 ///
11158 /// # Errors
11159 /// Returns an error if:
11160 /// - Authentication fails
11161 /// - The HTTP request fails
11162 /// - The server returns an error status
11163 /// - Any individual asset removal fails
11164 pub async fn revoke_manager(&self, manager_id: i64) -> Result<(), Error> {
11165 // First, get the manager to see which assets they have access to
11166 let manager = self.get_manager(manager_id).await?;
11167
11168 // Remove the manager's access to each asset
11169 for asset_uuid in &manager.assets {
11170 self.manager_remove_asset(manager_id, asset_uuid).await?;
11171 }
11172
11173 Ok(())
11174 }
11175
11176 /// Gets the current manager information as raw JSON.
11177 ///
11178 /// This method calls the `/managers/me` endpoint to retrieve information
11179 /// about the currently authenticated manager.
11180 ///
11181 /// # Errors
11182 /// Returns an error if:
11183 /// - Authentication fails
11184 /// - The HTTP request fails
11185 /// - The server returns an error status
11186 /// - The response cannot be parsed as JSON
11187 pub async fn get_current_manager_raw(&self) -> Result<serde_json::Value, Error> {
11188 self.request_json(Method::GET, &["managers", "me"], None::<&()>)
11189 .await
11190 }
11191
11192 /// Locks a manager account to prevent further operations.
11193 ///
11194 /// This method sends a PUT request to lock the specified manager, preventing any further
11195 /// operations on that manager account. This is typically used for security purposes or
11196 /// when a manager needs to be temporarily disabled.
11197 ///
11198 /// # Arguments
11199 /// * `manager_id` - The ID of the manager to lock
11200 ///
11201 /// # Returns
11202 /// Returns `Ok(())` if the manager was successfully locked.
11203 ///
11204 /// # Errors
11205 /// Returns an error if:
11206 /// - Authentication fails
11207 /// - The HTTP request fails
11208 /// - The server returns an error status
11209 /// - The manager ID is invalid or does not exist
11210 /// - The manager is already locked
11211 ///
11212 /// # Example
11213 /// ```no_run
11214 /// # use amp_rs::ApiClient;
11215 /// # #[tokio::main]
11216 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
11217 /// let client = ApiClient::new().await?;
11218 ///
11219 /// // Lock manager with ID 123
11220 /// client.lock_manager(123).await?;
11221 /// println!("Manager 123 has been locked successfully");
11222 /// # Ok(())
11223 /// # }
11224 /// ```
11225 pub async fn lock_manager(&self, manager_id: i64) -> Result<(), Error> {
11226 self.request_empty(
11227 Method::PUT,
11228 &["managers", &manager_id.to_string(), "lock"],
11229 None::<&()>,
11230 )
11231 .await
11232 }
11233
11234 /// Unlocks a manager account.
11235 ///
11236 /// # Arguments
11237 /// * `manager_id` - The ID of the manager to unlock
11238 ///
11239 /// # Errors
11240 /// Returns an error if:
11241 /// - Authentication fails
11242 /// - The HTTP request fails
11243 /// - The server returns an error status
11244 pub async fn unlock_manager(&self, manager_id: i64) -> Result<(), Error> {
11245 self.request_empty(
11246 Method::PUT,
11247 &["managers", &manager_id.to_string(), "unlock"],
11248 None::<&()>,
11249 )
11250 .await
11251 }
11252
11253 /// Authorizes a manager to manage a specific asset.
11254 ///
11255 /// This method sends a PUT request to authorize the specified manager to manage the given asset.
11256 /// Once authorized, the manager will have permissions to perform operations on the asset such as
11257 /// creating assignments, managing ownership, and other asset-related operations.
11258 ///
11259 /// # Arguments
11260 /// * `manager_id` - The ID of the manager to authorize
11261 /// * `asset_uuid` - The UUID of the asset to add to the manager's authorized assets
11262 ///
11263 /// # Returns
11264 /// Returns `Ok(())` if the manager was successfully authorized for the asset.
11265 ///
11266 /// # Errors
11267 /// Returns an error if:
11268 /// - Authentication fails or insufficient permissions
11269 /// - The HTTP request fails
11270 /// - The server returns an error status
11271 /// - The manager ID is invalid or does not exist
11272 /// - The asset UUID is invalid or does not exist
11273 /// - The manager is already authorized for this asset
11274 /// - The manager is locked and cannot be modified
11275 ///
11276 /// # Examples
11277 /// ```no_run
11278 /// # use amp_rs::ApiClient;
11279 /// # #[tokio::main]
11280 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
11281 /// let client = ApiClient::new().await?;
11282 ///
11283 /// // Authorize manager 123 to manage asset with UUID "550e8400-e29b-41d4-a716-446655440000"
11284 /// let manager_id = 123;
11285 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
11286 ///
11287 /// client.add_asset_to_manager(manager_id, asset_uuid).await?;
11288 /// println!("Manager {} is now authorized to manage asset {}", manager_id, asset_uuid);
11289 /// # Ok(())
11290 /// # }
11291 /// ```
11292 ///
11293 /// # Related Methods
11294 /// - [`manager_remove_asset`](Self::manager_remove_asset) - Remove manager permissions for an asset
11295 /// - [`get_manager`](Self::get_manager) - Get manager information including current assets
11296 /// - [`get_manager_permissions`](Self::get_manager_permissions) - Get manager's current permissions
11297 /// - [`lock_manager`](Self::lock_manager) - Lock manager account
11298 pub async fn add_asset_to_manager(
11299 &self,
11300 manager_id: i64,
11301 asset_uuid: &str,
11302 ) -> Result<(), Error> {
11303 self.request_empty(
11304 Method::PUT,
11305 &[
11306 "managers",
11307 &manager_id.to_string(),
11308 "assets",
11309 asset_uuid,
11310 "add",
11311 ],
11312 None::<&()>,
11313 )
11314 .await
11315 }
11316
11317 /// Deletes a specific asset assignment.
11318 ///
11319 /// # Arguments
11320 /// * `asset_uuid` - The UUID of the asset
11321 /// * `assignment_id` - The ID of the assignment to delete
11322 ///
11323 /// # Errors
11324 /// Returns an error if:
11325 /// - Authentication fails
11326 /// - The HTTP request fails
11327 /// - The server returns an error status
11328 /// Removes an asset assignment.
11329 ///
11330 /// This method permanently deletes an asset assignment, returning the allocated assets
11331 /// back to the available pool. This operation cannot be undone. If the assignment has
11332 /// already been distributed, this operation may fail.
11333 ///
11334 /// # Arguments
11335 /// * `asset_uuid` - The UUID of the asset containing the assignment
11336 /// * `assignment_id` - The ID of the assignment to delete
11337 ///
11338 /// # Returns
11339 /// Returns `Ok(())` on successful deletion.
11340 ///
11341 /// # Errors
11342 /// Returns an error if:
11343 /// - Authentication fails or insufficient permissions
11344 /// - The asset UUID is invalid or does not exist
11345 /// - The assignment ID is invalid or does not exist
11346 /// - The assignment has already been distributed and cannot be deleted
11347 /// - The assignment is locked and cannot be modified
11348 /// - The HTTP request fails
11349 /// - The server returns an error status
11350 ///
11351 /// # Examples
11352 /// ```no_run
11353 /// # use amp_rs::ApiClient;
11354 /// # #[tokio::main]
11355 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
11356 /// let client = ApiClient::new().await?;
11357 ///
11358 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
11359 /// let assignment_id = "123";
11360 ///
11361 /// client.delete_asset_assignment(asset_uuid, assignment_id).await?;
11362 /// println!("Successfully deleted assignment {}", assignment_id);
11363 /// # Ok(())
11364 /// # }
11365 /// ```
11366 ///
11367 /// # Related Methods
11368 /// - [`get_asset_assignment`](Self::get_asset_assignment) - Get assignment details before deletion
11369 /// - [`create_asset_assignments`](Self::create_asset_assignments) - Create new assignments
11370 /// - [`edit_asset_assignment`](Self::edit_asset_assignment) - Update assignment instead of deleting
11371 /// - [`lock_asset_assignment`](Self::lock_asset_assignment) - Lock assignment to prevent changes
11372 pub async fn delete_asset_assignment(
11373 &self,
11374 asset_uuid: &str,
11375 assignment_id: &str,
11376 ) -> Result<(), Error> {
11377 self.request_empty(
11378 Method::DELETE,
11379 &["assets", asset_uuid, "assignments", assignment_id, "delete"],
11380 None::<&()>,
11381 )
11382 .await
11383 }
11384
11385 /// Locks a specific asset assignment.
11386 ///
11387 /// # Arguments
11388 /// * `asset_uuid` - The UUID of the asset
11389 /// * `assignment_id` - The ID of the assignment to lock
11390 ///
11391 /// # Errors
11392 /// Returns an error if:
11393 /// - Authentication fails
11394 /// - The HTTP request fails
11395 /// - The server returns an error status
11396 pub async fn lock_asset_assignment(
11397 &self,
11398 asset_uuid: &str,
11399 assignment_id: &str,
11400 ) -> Result<Assignment, Error> {
11401 self.request_json(
11402 Method::PUT,
11403 &["assets", asset_uuid, "assignments", assignment_id, "lock"],
11404 None::<&()>,
11405 )
11406 .await
11407 }
11408
11409 /// Unlocks a specific asset assignment.
11410 ///
11411 /// # Arguments
11412 /// * `asset_uuid` - The UUID of the asset
11413 /// * `assignment_id` - The ID of the assignment to unlock
11414 ///
11415 /// # Errors
11416 /// Returns an error if:
11417 /// - Authentication fails
11418 /// - The HTTP request fails
11419 /// - The server returns an error status
11420 pub async fn unlock_asset_assignment(
11421 &self,
11422 asset_uuid: &str,
11423 assignment_id: &str,
11424 ) -> Result<Assignment, Error> {
11425 self.request_json(
11426 Method::PUT,
11427 &["assets", asset_uuid, "assignments", assignment_id, "unlock"],
11428 None::<&()>,
11429 )
11430 .await
11431 }
11432
11433 /// Adds categories to a registered user.
11434 ///
11435 /// # Arguments
11436 /// * `registered_user_id` - The ID of the registered user
11437 /// * `categories` - A slice of category IDs to add to the user
11438 ///
11439 /// # Errors
11440 /// Returns an error if:
11441 /// - Authentication fails
11442 /// - The HTTP request fails
11443 /// - The server returns an error status
11444 /// - The registered user ID is invalid
11445 /// - Any category ID is invalid
11446 pub async fn add_categories_to_registered_user(
11447 &self,
11448 registered_user_id: i64,
11449 categories: &[i64],
11450 ) -> Result<(), Error> {
11451 let request_body = CategoriesRequest {
11452 categories: categories.to_vec(),
11453 };
11454
11455 self.request_empty(
11456 Method::PUT,
11457 &[
11458 "registered_users",
11459 ®istered_user_id.to_string(),
11460 "categories",
11461 "add",
11462 ],
11463 Some(request_body),
11464 )
11465 .await
11466 }
11467
11468 /// Removes categories from a registered user
11469 ///
11470 /// # Arguments
11471 /// * `registered_user_id` - The ID of the registered user
11472 /// * `categories` - A slice of category IDs to remove from the user
11473 ///
11474 /// # Returns
11475 /// Returns `Ok(())` if the categories are successfully removed, or an error if:
11476 /// - Authentication fails
11477 /// - The HTTP request fails
11478 /// - The server returns an error status
11479 /// - The registered user ID is invalid
11480 /// - Any category ID is not associated with the user
11481 pub async fn remove_categories_from_registered_user(
11482 &self,
11483 registered_user_id: i64,
11484 categories: &[i64],
11485 ) -> Result<(), Error> {
11486 let request_body = CategoriesRequest {
11487 categories: categories.to_vec(),
11488 };
11489
11490 self.request_empty(
11491 Method::PUT,
11492 &[
11493 "registered_users",
11494 ®istered_user_id.to_string(),
11495 "categories",
11496 "delete",
11497 ],
11498 Some(request_body),
11499 )
11500 .await
11501 }
11502
11503 /// Distributes assets to multiple users through a comprehensive workflow
11504 ///
11505 /// This method orchestrates the complete asset distribution process:
11506 /// 1. Validates input parameters (asset UUID format, assignments structure)
11507 /// 2. Verifies `ElementsRpc` connection and signer interface availability
11508 /// 3. Authenticates with the AMP API using the client's token
11509 /// 4. Creates a distribution request via the AMP API
11510 /// 5. Constructs and signs the blockchain transaction using the provided signer
11511 /// 6. Broadcasts the transaction to the Elements network
11512 /// 7. Waits for blockchain confirmations (2 confirmations minimum)
11513 /// 8. Confirms the distribution with the AMP API
11514 ///
11515 /// # Arguments
11516 /// * `asset_uuid` - The UUID of the asset to distribute (must be valid UUID format)
11517 /// * `assignments` - Vector of assignments specifying `user_id`, address, and amount
11518 /// * `node_rpc` - `ElementsRpc` client for blockchain operations
11519 /// * `signer` - Signer implementation for transaction signing
11520 ///
11521 /// # Returns
11522 /// Returns `Ok(())` if the distribution completes successfully, or an `AmpError` if:
11523 /// - Input validation fails (invalid UUID format, empty assignments, etc.)
11524 /// - `ElementsRpc` connection cannot be established
11525 /// - Signer interface is not available
11526 /// - Authentication with AMP API fails
11527 /// - Distribution creation fails
11528 /// - Transaction construction or signing fails
11529 /// - Blockchain broadcasting fails
11530 /// - Confirmation timeout occurs
11531 /// - Distribution confirmation with AMP API fails
11532 ///
11533 /// # Examples
11534 /// ```no_run
11535 /// # use amp_rs::{ApiClient, ElementsRpc, AmpError};
11536 /// # use amp_rs::model::AssetDistributionAssignment;
11537 /// # use amp_rs::signer::{Signer, LwkSoftwareSigner};
11538 /// # #[tokio::main]
11539 /// # async fn main() -> Result<(), AmpError> {
11540 /// let client = ApiClient::new().await?;
11541 /// let elements_rpc = ElementsRpc::from_env()?;
11542 /// let (_, signer) = LwkSoftwareSigner::generate_new()?;
11543 ///
11544 /// let assignments = vec![
11545 /// AssetDistributionAssignment {
11546 /// user_id: "user123".to_string(),
11547 /// address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
11548 /// amount: 100.0,
11549 /// },
11550 /// ];
11551 ///
11552 /// client.distribute_asset(
11553 /// "550e8400-e29b-41d4-a716-446655440000",
11554 /// assignments,
11555 /// &elements_rpc,
11556 /// "wallet_name",
11557 /// &signer
11558 /// ).await?;
11559 /// # Ok(())
11560 /// # }
11561 /// ```
11562 ///
11563 /// # Requirements
11564 /// This method implements requirements:
11565 /// - 1.1: Single method for complete distribution workflow
11566 /// - 2.2: Assignment details validation
11567 /// - 2.4: Input validation for all parameters
11568 /// - 5.1: Comprehensive error handling with context
11569 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
11570 pub async fn distribute_asset(
11571 &self,
11572 asset_uuid: &str,
11573 assignments: Vec<AssetDistributionAssignment>,
11574 node_rpc: &ElementsRpc,
11575 wallet_name: &str,
11576 signer: &dyn Signer,
11577 ) -> Result<(), AmpError> {
11578 let distribution_span = tracing::info_span!(
11579 "distribute_asset",
11580 asset_uuid = %asset_uuid,
11581 assignment_count = assignments.len()
11582 );
11583 let _enter = distribution_span.enter();
11584
11585 tracing::info!(
11586 "Starting asset distribution workflow for asset: {} with {} assignments",
11587 asset_uuid,
11588 assignments.len()
11589 );
11590
11591 // Step 1: Input validation - asset_uuid format
11592 tracing::debug!("Step 1: Validating asset UUID format");
11593 Self::validate_asset_uuid(asset_uuid).map_err(|e| {
11594 let error = AmpError::validation(format!("Invalid asset UUID: {e}"));
11595 tracing::error!("Asset UUID validation failed: {}", e);
11596 error.with_context("Step 1: Asset UUID validation")
11597 })?;
11598 tracing::debug!("Asset UUID validation passed");
11599
11600 // Step 2: Input validation - assignments data structure
11601 tracing::debug!("Step 2: Validating {} assignments", assignments.len());
11602 Self::validate_assignments(&assignments).map_err(|e| {
11603 let error = AmpError::validation(format!("Invalid assignments: {e}"));
11604 tracing::error!("Assignments validation failed: {}", e);
11605 error.with_context("Step 2: Assignments validation")
11606 })?;
11607 tracing::debug!("Assignments validation passed");
11608
11609 // Step 3: Check ElementsRpc connection availability
11610 tracing::debug!("Step 3: Validating Elements RPC connection");
11611 self.validate_elements_rpc_connection(node_rpc)
11612 .await
11613 .map_err(|e| {
11614 let error = AmpError::rpc(format!("ElementsRpc connection validation failed: {e}"));
11615 tracing::error!("Elements RPC connection validation failed: {}", e);
11616 error.with_context("Step 3: Elements RPC connection validation")
11617 })?;
11618 tracing::debug!("Elements RPC connection validation passed");
11619
11620 // Step 4: Check signer interface availability
11621 tracing::debug!("Step 4: Validating signer interface");
11622 self.validate_signer_interface(signer).await.map_err(|e| {
11623 let error = AmpError::validation(format!("Signer interface validation failed: {e}"));
11624 tracing::error!("Signer interface validation failed: {}", e);
11625 error.with_context("Step 4: Signer interface validation")
11626 })?;
11627 tracing::debug!("Signer interface validation passed");
11628
11629 tracing::info!("✓ All input validations completed successfully");
11630
11631 // Step 5: Authenticate with AMP API using existing TokenManager
11632 tracing::debug!("Step 5: Authenticating with AMP API");
11633 let _token = self.token_strategy.get_token().await.map_err(|e| {
11634 tracing::error!("AMP API authentication failed: {}", e);
11635 let amp_error = AmpError::Existing(e);
11636 if amp_error.is_retryable() {
11637 if let Some(instructions) = amp_error.retry_instructions() {
11638 tracing::warn!("Retry instructions: {}", instructions);
11639 }
11640 }
11641 amp_error.with_context("Step 5: AMP API authentication")
11642 })?;
11643 tracing::info!("✓ Successfully authenticated with AMP API");
11644
11645 // Step 6: Create distribution request and parse response data
11646 tracing::debug!(
11647 "Step 6: Creating distribution request with {} assignments",
11648 assignments.len()
11649 );
11650 let distribution_response = self
11651 .create_distribution(asset_uuid, assignments)
11652 .await
11653 .map_err(|e| {
11654 tracing::error!("Distribution creation failed: {}", e);
11655 if e.is_retryable() {
11656 if let Some(instructions) = e.retry_instructions() {
11657 tracing::warn!("Retry instructions: {}", instructions);
11658 }
11659 }
11660 e.with_context("Step 6: Distribution creation")
11661 })?;
11662
11663 tracing::info!(
11664 "✓ Distribution created successfully: {} with asset_id: {}",
11665 distribution_response.distribution_uuid,
11666 distribution_response.asset_id
11667 );
11668
11669 // Step 7: Verify Elements node status and execute transaction workflow
11670 tracing::debug!("Step 7: Verifying Elements node status");
11671 let (network_info, blockchain_info) = node_rpc.get_node_status().await.map_err(|e| {
11672 tracing::error!("Elements node status verification failed: {}", e);
11673 if e.is_retryable() {
11674 if let Some(instructions) = e.retry_instructions() {
11675 tracing::warn!("Retry instructions: {}", instructions);
11676 }
11677 }
11678 e.with_context("Step 7: Elements node status verification")
11679 })?;
11680
11681 tracing::info!(
11682 "✓ Elements node verified - chain: {}, blocks: {}, connections: {}",
11683 blockchain_info.chain,
11684 blockchain_info.blocks,
11685 network_info.connections
11686 );
11687
11688 // Step 8: Send distribution transaction using Elements' sendmany
11689 tracing::debug!("Step 8: Sending distribution transaction using Elements sendmany");
11690
11691 // Create asset amounts map for sendmany (all outputs use the same asset)
11692 let mut asset_amounts = std::collections::HashMap::new();
11693 for address in distribution_response.map_address_amount.keys() {
11694 asset_amounts.insert(address.clone(), distribution_response.asset_id.clone());
11695 }
11696
11697 tracing::info!(
11698 "Using sendmany for {} outputs with asset {}",
11699 distribution_response.map_address_amount.len(),
11700 distribution_response.asset_id
11701 );
11702
11703 // Use Elements' sendmany which properly handles confidential transactions
11704 let txid = node_rpc
11705 .sendmany(
11706 wallet_name,
11707 distribution_response.map_address_amount.clone(),
11708 asset_amounts,
11709 Some(0), // min_conf: 0 to include unconfirmed UTXOs (matches Python implementation)
11710 Some("AMP asset distribution"), // comment
11711 None, // subtract_fee_from: let Elements handle fees automatically
11712 Some(false), // replaceable: false for final transactions
11713 Some(1), // conf_target: 1 block for faster confirmation
11714 Some("UNSET"), // estimate_mode: let Elements choose
11715 )
11716 .await
11717 .map_err(|e| {
11718 tracing::error!("Sendmany transaction failed: {}", e);
11719 if e.is_retryable() {
11720 if let Some(instructions) = e.retry_instructions() {
11721 tracing::warn!("Retry instructions: {}", instructions);
11722 }
11723 }
11724 e.with_context("Step 8: Sendmany transaction")
11725 })?;
11726
11727 tracing::info!("✓ Transaction sent successfully with ID: {}", txid);
11728
11729 // Step 9: Wait for confirmations
11730 tracing::debug!("Step 9: Waiting for blockchain confirmations (minimum 2 confirmations, 10-minute timeout)");
11731 let confirmation_start = std::time::Instant::now();
11732 let tx_detail = node_rpc.wait_for_confirmations(&txid, Some(2), Some(10)).await
11733 .map_err(|e| {
11734 let elapsed = confirmation_start.elapsed();
11735 tracing::error!(
11736 "Confirmation waiting failed after {:?}: {}",
11737 elapsed,
11738 e
11739 );
11740
11741 if let AmpError::Timeout(_) = &e {
11742 tracing::warn!(
11743 "Confirmation timeout - transaction {} may still be pending. \
11744 Use this txid to manually confirm the distribution if it gets confirmed later.",
11745 txid
11746 );
11747 let timeout_error = AmpError::timeout(format!(
11748 "Confirmation timeout for txid: {txid}. Use this txid to manually confirm the distribution."
11749 ));
11750 timeout_error.with_context("Step 9: Confirmation waiting")
11751 } else {
11752 if e.is_retryable() {
11753 if let Some(instructions) = e.retry_instructions() {
11754 tracing::warn!("Retry instructions: {}", instructions);
11755 }
11756 }
11757 e.with_context(format!("Step 9: Confirmation waiting for txid: {txid}"))
11758 }
11759 })?;
11760
11761 let confirmation_duration = confirmation_start.elapsed();
11762 tracing::info!(
11763 "✓ Transaction confirmed with {} confirmations at block height: {:?} (took {:?})",
11764 tx_detail.confirmations,
11765 tx_detail.blockheight,
11766 confirmation_duration
11767 );
11768
11769 // Step 10: Collect change data for confirmation
11770 tracing::debug!("Step 10: Collecting change data for distribution confirmation");
11771 let change_data = node_rpc
11772 .collect_change_data(
11773 &distribution_response.asset_id,
11774 &txid,
11775 node_rpc,
11776 wallet_name,
11777 )
11778 .await
11779 .map_err(|e| {
11780 tracing::error!("Change data collection failed: {}", e);
11781 if e.is_retryable() {
11782 if let Some(instructions) = e.retry_instructions() {
11783 tracing::warn!("Retry instructions: {}", instructions);
11784 }
11785 }
11786 e.with_context("Step 10: Change data collection")
11787 })?;
11788
11789 tracing::info!("✓ Collected {} change UTXOs", change_data.len());
11790 if !change_data.is_empty() {
11791 tracing::debug!("Change UTXOs: {:?}", change_data);
11792 }
11793
11794 // Step 11: Submit final confirmation to AMP API
11795 tracing::debug!("Step 11: Submitting final confirmation to AMP API");
11796
11797 // Extract the details field from the transaction (matching Python implementation)
11798 // Python: details = rpc.call('gettransaction', txid).get('details')
11799 let transaction_details = tx_detail.details.unwrap_or_else(Vec::new);
11800 tracing::debug!(
11801 "Transaction details for confirmation: {:?}",
11802 transaction_details
11803 );
11804
11805 let amp_tx_data = crate::model::AmpTxData {
11806 details: serde_json::Value::Array(transaction_details),
11807 txid: txid.clone(),
11808 };
11809
11810 // Log the exact payload being sent to AMP for debugging
11811 tracing::info!("Sending confirmation payload to AMP:");
11812 tracing::info!(" tx_data.txid: {}", amp_tx_data.txid);
11813 tracing::info!(" tx_data.details: {:?}", amp_tx_data.details);
11814 tracing::info!(" change_data: {} UTXOs", change_data.len());
11815
11816 let confirmation_request = crate::model::ConfirmDistributionRequest {
11817 tx_data: amp_tx_data.clone(),
11818 change_data: change_data.clone(),
11819 };
11820
11821 if let Ok(payload_json) = serde_json::to_string_pretty(&confirmation_request) {
11822 tracing::debug!("Full confirmation payload: {}", payload_json);
11823 }
11824
11825 self.confirm_distribution(
11826 asset_uuid,
11827 &distribution_response.distribution_uuid,
11828 amp_tx_data,
11829 change_data,
11830 )
11831 .await
11832 .map_err(|e| {
11833 tracing::error!("Distribution confirmation failed: {}", e);
11834
11835 // For confirmation failures, always provide retry instructions with txid
11836 let confirmation_error = AmpError::api(format!(
11837 "Failed to confirm distribution {}: {}. \
11838 IMPORTANT: Transaction {} was successful on blockchain. \
11839 Use this txid to manually retry confirmation.",
11840 distribution_response.distribution_uuid, e, txid
11841 ));
11842
11843 if e.is_retryable() {
11844 if let Some(instructions) = e.retry_instructions() {
11845 tracing::warn!("Retry instructions: {}", instructions);
11846 }
11847 }
11848
11849 confirmation_error.with_context("Step 11: Distribution confirmation")
11850 })?;
11851
11852 tracing::info!(
11853 "🎉 Asset distribution completed successfully for asset: {} with transaction: {}",
11854 asset_uuid,
11855 txid
11856 );
11857
11858 Ok(())
11859 }
11860
11861 /// Validates the asset UUID format
11862 ///
11863 /// Ensures the asset UUID follows the standard UUID format (8-4-4-4-12 hexadecimal digits)
11864 ///
11865 /// # Arguments
11866 /// * `asset_uuid` - The asset UUID string to validate
11867 ///
11868 /// # Returns
11869 /// Returns `Ok(())` if valid, or an error describing the validation failure
11870 ///
11871 /// # Errors
11872 /// - Empty or whitespace-only UUID
11873 /// - Invalid UUID format (not matching standard UUID pattern)
11874 /// - UUID contains invalid characters
11875 fn validate_asset_uuid(asset_uuid: &str) -> Result<(), String> {
11876 if asset_uuid.trim().is_empty() {
11877 return Err("Asset UUID cannot be empty".to_string());
11878 }
11879
11880 // Basic UUID format validation (8-4-4-4-12 pattern)
11881 // Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
11882 let parts: Vec<&str> = asset_uuid.split('-').collect();
11883 if parts.len() != 5 {
11884 return Err(format!(
11885 "Asset UUID '{asset_uuid}' does not have 5 parts separated by hyphens"
11886 ));
11887 }
11888
11889 // Check each part has the correct length and contains only hex characters
11890 let expected_lengths = [8, 4, 4, 4, 12];
11891 for (i, (part, &expected_len)) in parts.iter().zip(expected_lengths.iter()).enumerate() {
11892 if part.len() != expected_len {
11893 return Err(format!(
11894 "Asset UUID part {} has length {} but expected {}",
11895 i + 1,
11896 part.len(),
11897 expected_len
11898 ));
11899 }
11900
11901 // Check if all characters are valid hexadecimal
11902 if !part.chars().all(|c| c.is_ascii_hexdigit()) {
11903 return Err(format!(
11904 "Asset UUID part {} contains non-hexadecimal characters: '{}'",
11905 i + 1,
11906 part
11907 ));
11908 }
11909 }
11910
11911 tracing::debug!("Asset UUID validation passed: {}", asset_uuid);
11912 Ok(())
11913 }
11914
11915 /// Validates the assignments data structure
11916 ///
11917 /// Ensures assignments vector is not empty and each assignment has valid data
11918 ///
11919 /// # Arguments
11920 /// * `assignments` - Vector of assignments to validate
11921 ///
11922 /// # Returns
11923 /// Returns `Ok(())` if valid, or an error describing the validation failure
11924 ///
11925 /// # Errors
11926 /// - Empty assignments vector
11927 /// - Assignment with empty `user_id`
11928 /// - Assignment with empty address
11929 /// - Assignment with non-positive amount
11930 /// - Assignment with invalid address format
11931 #[allow(clippy::cognitive_complexity)]
11932 fn validate_assignments(assignments: &[AssetDistributionAssignment]) -> Result<(), String> {
11933 tracing::debug!("Validating {} assignments", assignments.len());
11934
11935 if assignments.is_empty() {
11936 tracing::error!("Assignments validation failed: empty assignments vector");
11937 return Err("Assignments vector cannot be empty".to_string());
11938 }
11939
11940 let mut total_amount = 0.0;
11941 let mut unique_addresses = std::collections::HashSet::new();
11942 let mut unique_users = std::collections::HashSet::new();
11943
11944 for (index, assignment) in assignments.iter().enumerate() {
11945 tracing::trace!(
11946 "Validating assignment {}: user_id={}, address={}, amount={}",
11947 index,
11948 assignment.user_id,
11949 assignment.address,
11950 assignment.amount
11951 );
11952
11953 // Validate user_id
11954 if assignment.user_id.trim().is_empty() {
11955 tracing::error!("Assignment {} validation failed: empty user_id", index);
11956 return Err(format!("Assignment {index} has empty user_id"));
11957 }
11958
11959 // Validate address
11960 if assignment.address.trim().is_empty() {
11961 tracing::error!("Assignment {} validation failed: empty address", index);
11962 return Err(format!("Assignment {index} has empty address"));
11963 }
11964
11965 // Basic address format validation (should start with appropriate prefix for Liquid)
11966 if !assignment.address.starts_with("lq")
11967 && !assignment.address.starts_with("vj")
11968 && !assignment.address.starts_with("VJ")
11969 && !assignment.address.starts_with("VT")
11970 {
11971 tracing::error!(
11972 "Assignment {} validation failed: invalid address format '{}' (should start with 'lq', 'vj', 'VJ', or 'VT')",
11973 index, assignment.address
11974 );
11975 return Err(format!(
11976 "Assignment {} has invalid address format: '{}' (should start with 'lq', 'vj', 'VJ', or 'VT')",
11977 index, assignment.address
11978 ));
11979 }
11980
11981 // Validate amount
11982 if assignment.amount <= 0.0 {
11983 tracing::error!(
11984 "Assignment {} validation failed: non-positive amount {}",
11985 index,
11986 assignment.amount
11987 );
11988 return Err(format!(
11989 "Assignment {} has non-positive amount: {}",
11990 index, assignment.amount
11991 ));
11992 }
11993
11994 // Check for reasonable amount limits (prevent overflow issues)
11995 if assignment.amount > 21_000_000.0 {
11996 tracing::error!(
11997 "Assignment {} validation failed: unreasonably large amount {} (max: 21,000,000)",
11998 index, assignment.amount
11999 );
12000 return Err(format!(
12001 "Assignment {} has unreasonably large amount: {} (max: 21,000,000)",
12002 index, assignment.amount
12003 ));
12004 }
12005
12006 // Check for precision issues (more than 8 decimal places)
12007 let amount_str = format!("{:.8}", assignment.amount);
12008 if amount_str.len() > 20 {
12009 // Reasonable length check
12010 tracing::warn!(
12011 "Assignment {} has high precision amount: {} - may cause precision issues",
12012 index,
12013 assignment.amount
12014 );
12015 }
12016
12017 // Track duplicates for warnings
12018 if !unique_addresses.insert(&assignment.address) {
12019 tracing::warn!(
12020 "Assignment {} uses duplicate address: {} (this may be intentional)",
12021 index,
12022 assignment.address
12023 );
12024 }
12025
12026 if !unique_users.insert(&assignment.user_id) {
12027 tracing::warn!(
12028 "Assignment {} uses duplicate user_id: {} (this may be intentional)",
12029 index,
12030 assignment.user_id
12031 );
12032 }
12033
12034 total_amount += assignment.amount;
12035 }
12036
12037 tracing::debug!(
12038 "Assignments validation passed - {} assignments, total amount: {}, unique addresses: {}, unique users: {}",
12039 assignments.len(),
12040 total_amount,
12041 unique_addresses.len(),
12042 unique_users.len()
12043 );
12044
12045 if total_amount > 100_000_000.0 {
12046 tracing::warn!(
12047 "Total distribution amount is very large: {} - ensure this is intentional",
12048 total_amount
12049 );
12050 }
12051
12052 Ok(())
12053 }
12054
12055 /// Validates `ElementsRpc` connection availability
12056 ///
12057 /// Attempts to connect to the Elements node and verify basic functionality
12058 ///
12059 /// # Arguments
12060 /// * `node_rpc` - `ElementsRpc` client to validate
12061 ///
12062 /// # Returns
12063 /// Returns `Ok(())` if connection is valid, or an error describing the failure
12064 ///
12065 /// # Errors
12066 /// - Cannot connect to Elements node
12067 /// - Node is not synchronized
12068 /// - Node version is incompatible
12069 /// - RPC authentication fails
12070 #[allow(clippy::cognitive_complexity)]
12071 async fn validate_elements_rpc_connection(&self, node_rpc: &ElementsRpc) -> Result<(), String> {
12072 tracing::debug!("Validating Elements RPC connection");
12073
12074 // Test basic connectivity by getting network info
12075 tracing::trace!("Testing Elements RPC connectivity with getnetworkinfo");
12076 let network_info = node_rpc.get_network_info().await.map_err(|e| {
12077 tracing::error!("Failed to get network info from Elements node: {}", e);
12078 format!("Failed to get network info: {e}")
12079 })?;
12080
12081 tracing::debug!(
12082 "Network info retrieved - version: {}, connections: {}, network_active: {}",
12083 network_info.version,
12084 network_info.connections,
12085 network_info.networkactive
12086 );
12087
12088 // Check if network is active
12089 if !network_info.networkactive {
12090 tracing::error!("Elements node network is not active");
12091 return Err("Elements node network is not active".to_string());
12092 }
12093
12094 // Verify we have active connections (for non-regtest environments)
12095 if network_info.connections == 0 {
12096 tracing::warn!("Elements node has no peer connections (may be regtest environment)");
12097 } else {
12098 tracing::debug!(
12099 "Elements node has {} peer connections",
12100 network_info.connections
12101 );
12102 }
12103
12104 // Test blockchain info to ensure node is operational
12105 tracing::trace!("Testing Elements RPC with getblockchaininfo");
12106 let blockchain_info = node_rpc.get_blockchain_info().await.map_err(|e| {
12107 tracing::error!("Failed to get blockchain info from Elements node: {}", e);
12108 format!("Failed to get blockchain info: {e}")
12109 })?;
12110
12111 let sync_progress = blockchain_info.verificationprogress.unwrap_or(1.0) * 100.0;
12112 tracing::debug!(
12113 "Blockchain info retrieved - chain: {}, blocks: {}, sync_progress: {:.2}%",
12114 blockchain_info.chain,
12115 blockchain_info.blocks,
12116 sync_progress
12117 );
12118
12119 // Check if node is still in initial block download
12120 if blockchain_info.initialblockdownload.unwrap_or(false) {
12121 tracing::error!(
12122 "Elements node is still in initial block download (sync progress: {:.2}%)",
12123 sync_progress
12124 );
12125 return Err(format!(
12126 "Elements node is still in initial block download (sync progress: {sync_progress:.2}%)"
12127 ));
12128 }
12129
12130 // Check sync progress
12131 if blockchain_info.verificationprogress.unwrap_or(1.0) < 0.99 {
12132 tracing::warn!(
12133 "Elements node may not be fully synced (sync progress: {:.2}%)",
12134 sync_progress
12135 );
12136 }
12137
12138 // Check for warnings
12139 if !network_info.warnings.is_empty() {
12140 tracing::warn!("Elements node network warnings: {}", network_info.warnings);
12141 }
12142
12143 if let Some(warnings) = &blockchain_info.warnings {
12144 if !warnings.is_empty() {
12145 tracing::warn!("Elements node blockchain warnings: {}", warnings);
12146 }
12147 }
12148
12149 tracing::debug!(
12150 "ElementsRpc connection validation passed - chain: {}, blocks: {}, connections: {}, sync: {:.2}%",
12151 blockchain_info.chain,
12152 blockchain_info.blocks,
12153 network_info.connections,
12154 sync_progress
12155 );
12156
12157 Ok(())
12158 }
12159
12160 /// Validates signer interface availability
12161 ///
12162 /// Tests the signer interface with a dummy transaction to ensure it's functional
12163 ///
12164 /// # Arguments
12165 /// * `signer` - Signer implementation to validate
12166 ///
12167 /// # Returns
12168 /// Returns `Ok(())` if signer is functional, or an error describing the failure
12169 ///
12170 /// # Errors
12171 /// - Signer interface is not responsive
12172 /// - Signer fails basic functionality test
12173 #[allow(clippy::cognitive_complexity)]
12174 async fn validate_signer_interface(&self, signer: &dyn Signer) -> Result<(), String> {
12175 tracing::debug!("Validating signer interface");
12176
12177 // Test signer with a minimal dummy transaction hex
12178 // This is a minimal Elements transaction structure that should parse but not be valid for signing
12179 let dummy_tx = "0200000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
12180
12181 tracing::trace!("Testing signer interface with dummy transaction");
12182
12183 // Attempt to sign the dummy transaction - we expect this to fail with a specific error
12184 // but the signer should be responsive and not panic
12185 let validation_start = std::time::Instant::now();
12186 match signer.sign_transaction(dummy_tx).await {
12187 Ok(signed_tx) => {
12188 // Unexpected success with dummy transaction - this might indicate an issue
12189 tracing::warn!(
12190 "Signer unexpectedly succeeded with dummy transaction (returned: {} chars)",
12191 signed_tx.len()
12192 );
12193 tracing::debug!("Signer validation passed despite unexpected success");
12194 }
12195 Err(SignerError::InvalidTransaction(msg)) => {
12196 // Expected error - signer is working and correctly identified invalid transaction
12197 tracing::debug!(
12198 "Signer interface validation passed - correctly rejected dummy transaction: {}",
12199 msg
12200 );
12201 }
12202 Err(SignerError::HexParse(msg)) => {
12203 // Also acceptable - signer is working and correctly identified parsing issue
12204 tracing::debug!(
12205 "Signer interface validation passed - correctly identified hex parsing issue: {}",
12206 msg
12207 );
12208 }
12209 Err(SignerError::Lwk(msg)) => {
12210 // LWK-specific errors might be acceptable depending on the message
12211 if msg.contains("invalid") || msg.contains("parse") || msg.contains("decode") {
12212 tracing::debug!(
12213 "Signer interface validation passed - LWK correctly identified invalid transaction: {}",
12214 msg
12215 );
12216 } else {
12217 tracing::error!("Signer interface test failed with LWK error: {}", msg);
12218 return Err(format!(
12219 "Signer interface test failed with LWK error: {msg}"
12220 ));
12221 }
12222 }
12223 Err(e) => {
12224 // Other errors might indicate signer interface issues
12225 tracing::error!("Signer interface test failed: {}", e);
12226 return Err(format!("Signer interface test failed: {e}"));
12227 }
12228 }
12229
12230 let validation_duration = validation_start.elapsed();
12231 tracing::debug!(
12232 "Signer interface validation completed in {:?}",
12233 validation_duration
12234 );
12235
12236 // Warn if signer is very slow (might indicate performance issues)
12237 if validation_duration > std::time::Duration::from_secs(5) {
12238 tracing::warn!(
12239 "Signer interface validation took {:?} - this may indicate performance issues",
12240 validation_duration
12241 );
12242 }
12243
12244 Ok(())
12245 }
12246}
12247
12248fn get_amp_api_base_url() -> Result<Url, Error> {
12249 let url_str = env::var("AMP_API_BASE_URL")
12250 .unwrap_or_else(|_| "https://amp-test.blockstream.com/api".to_string());
12251 Url::parse(&url_str).map_err(Error::from)
12252}
12253
12254/// Creates a token strategy based on automatic environment detection
12255///
12256/// This function detects the current environment and creates the appropriate strategy:
12257/// - Mock strategy for mock environments (isolated, no persistence)
12258/// - Live strategy for live environments (full token management)
12259///
12260/// # Arguments
12261/// * `mock_token` - Optional token to use for mock environments
12262///
12263/// # Errors
12264/// Returns an error if strategy creation fails
12265pub async fn create_auto_token_strategy(
12266 mock_token: Option<String>,
12267) -> Result<Box<dyn TokenStrategy>, Error> {
12268 TokenEnvironment::create_auto_strategy(mock_token).await
12269}
12270
12271/// Creates a mock token strategy with the specified token
12272///
12273/// # Arguments
12274/// * `token` - The mock token to use
12275#[must_use]
12276pub fn create_mock_token_strategy(token: String) -> Box<dyn TokenStrategy> {
12277 Box::new(MockTokenStrategy::new(token))
12278}
12279
12280/// Creates a live token strategy with default configuration
12281///
12282/// # Errors
12283/// Returns an error if the `TokenManager` cannot be initialized
12284pub async fn create_live_token_strategy() -> Result<Box<dyn TokenStrategy>, Error> {
12285 let strategy = LiveTokenStrategy::new().await?;
12286 Ok(Box::new(strategy))
12287}
12288
12289/// Creates a token strategy for the specified environment
12290///
12291/// # Arguments
12292/// * `environment` - The target environment
12293/// * `mock_token` - Optional token to use for mock environments
12294///
12295/// # Errors
12296/// Returns an error if strategy creation fails
12297pub async fn create_token_strategy_for_environment(
12298 environment: TokenEnvironment,
12299 mock_token: Option<String>,
12300) -> Result<Box<dyn TokenStrategy>, Error> {
12301 environment.create_strategy(mock_token).await
12302}
12303
12304#[cfg(test)]
12305mod tests {
12306 use super::*;
12307 use crate::signer::LwkSoftwareSigner;
12308 use tokio;
12309
12310 #[tokio::test]
12311 async fn test_mock_token_strategy_basic_functionality() {
12312 let mock_token = "mock_token_12_345".to_string();
12313 let strategy = MockTokenStrategy::new(mock_token.clone());
12314
12315 // Test get_token returns the mock token
12316 let result = strategy.get_token().await;
12317 assert!(result.is_ok());
12318 assert_eq!(result.unwrap(), mock_token);
12319
12320 // Test strategy type identification
12321 assert_eq!(strategy.strategy_type(), "mock");
12322
12323 // Test persistence is disabled
12324 assert!(!strategy.should_persist());
12325
12326 // Test clear_token is a no-op (should not fail)
12327 let clear_result = strategy.clear_token().await;
12328 assert!(clear_result.is_ok());
12329
12330 // Verify token is still available after clear (since it's a no-op for mock)
12331 let token_after_clear = strategy.get_token().await;
12332 assert!(token_after_clear.is_ok());
12333 assert_eq!(token_after_clear.unwrap(), mock_token);
12334 }
12335
12336 #[tokio::test]
12337 async fn test_mock_token_strategy_isolation() {
12338 let token1 = "token_instance_1".to_string();
12339 let token2 = "token_instance_2".to_string();
12340
12341 let strategy1 = MockTokenStrategy::new(token1.clone());
12342 let strategy2 = MockTokenStrategy::new(token2.clone());
12343
12344 // Test that different instances are isolated
12345 let result1 = strategy1.get_token().await.unwrap();
12346 let result2 = strategy2.get_token().await.unwrap();
12347
12348 assert_eq!(result1, token1);
12349 assert_eq!(result2, token2);
12350 assert_ne!(result1, result2);
12351
12352 // Test that operations on one don't affect the other
12353 let _ = strategy1.clear_token().await;
12354 let result2_after_clear = strategy2.get_token().await.unwrap();
12355 assert_eq!(result2_after_clear, token2);
12356 }
12357
12358 #[tokio::test]
12359 async fn test_live_token_strategy_creation() {
12360 // Test creating a live strategy with global instance
12361 let strategy_result = LiveTokenStrategy::new().await;
12362 assert!(strategy_result.is_ok());
12363
12364 let strategy = strategy_result.unwrap();
12365 assert_eq!(strategy.strategy_type(), "live");
12366 assert!(strategy.should_persist());
12367 }
12368
12369 #[tokio::test]
12370 async fn test_live_token_strategy_with_custom_manager() {
12371 // Create a custom token manager for testing
12372 let config = RetryConfig::for_tests();
12373 let base_url = Url::parse("http://localhost:8080").unwrap();
12374 let mock_token = "test_live_token".to_string();
12375
12376 let token_manager =
12377 Arc::new(TokenManager::with_mock_token(config, base_url, mock_token.clone()).unwrap());
12378
12379 let strategy = LiveTokenStrategy::with_token_manager(token_manager);
12380
12381 // Test strategy properties
12382 assert_eq!(strategy.strategy_type(), "live");
12383 assert!(strategy.should_persist());
12384
12385 // Test token retrieval
12386 let token_result = strategy.get_token().await;
12387 assert!(token_result.is_ok());
12388 assert_eq!(token_result.unwrap(), mock_token);
12389 }
12390
12391 #[tokio::test]
12392 async fn test_live_token_strategy_clear_token() {
12393 // Create a live strategy with a mock token manager
12394 let config = RetryConfig::for_tests();
12395 let base_url = Url::parse("http://localhost:8080").unwrap();
12396 let mock_token = "test_clear_token".to_string();
12397
12398 let token_manager =
12399 Arc::new(TokenManager::with_mock_token(config, base_url, mock_token.clone()).unwrap());
12400
12401 let strategy = LiveTokenStrategy::with_token_manager(token_manager);
12402
12403 // Verify token is available initially
12404 let initial_token = strategy.get_token().await;
12405 assert!(initial_token.is_ok());
12406 assert_eq!(initial_token.unwrap(), mock_token);
12407
12408 // Clear the token
12409 let clear_result = strategy.clear_token().await;
12410 assert!(clear_result.is_ok());
12411
12412 // Note: After clearing, the TokenManager would try to obtain a new token
12413 // In a real scenario, this would fail without proper credentials
12414 // But our mock token manager will still return the same token
12415 }
12416
12417 #[tokio::test]
12418 async fn test_strategy_type_identification() {
12419 let mock_strategy = MockTokenStrategy::new("test_token".to_string());
12420 let live_strategy = LiveTokenStrategy::new().await.unwrap();
12421
12422 // Test that we can identify strategy types for debugging
12423 assert_eq!(mock_strategy.strategy_type(), "mock");
12424 assert_eq!(live_strategy.strategy_type(), "live");
12425
12426 // Test persistence settings
12427 assert!(!mock_strategy.should_persist());
12428 assert!(live_strategy.should_persist());
12429 }
12430
12431 #[tokio::test]
12432 async fn test_strategy_debug_formatting() {
12433 let mock_strategy = MockTokenStrategy::new("debug_test_token".to_string());
12434 let debug_output = format!("{mock_strategy:?}");
12435
12436 // Verify debug output contains expected information
12437 assert!(debug_output.contains("MockTokenStrategy"));
12438 assert!(debug_output.contains("debug_test_token"));
12439 }
12440
12441 // Environment Detection Tests
12442
12443 #[test]
12444 fn test_token_environment_detect_live_via_amp_tests() {
12445 // Set up environment for live test detection
12446 env::set_var("AMP_TESTS", "live");
12447 env::set_var("AMP_USERNAME", "real_user");
12448 env::set_var("AMP_PASSWORD", "real_pass");
12449 env::remove_var("AMP_API_BASE_URL");
12450
12451 let environment = TokenEnvironment::detect();
12452 assert_eq!(environment, TokenEnvironment::Live);
12453
12454 // Clean up
12455 env::remove_var("AMP_TESTS");
12456 env::remove_var("AMP_USERNAME");
12457 env::remove_var("AMP_PASSWORD");
12458 }
12459
12460 #[test]
12461 fn test_token_environment_detect_mock_via_credentials() {
12462 // Set up environment for mock detection via username
12463 env::remove_var("AMP_TESTS");
12464 env::set_var("AMP_USERNAME", "mock_user");
12465 env::set_var("AMP_PASSWORD", "real_pass");
12466 env::remove_var("AMP_API_BASE_URL");
12467
12468 let environment = TokenEnvironment::detect();
12469 assert_eq!(environment, TokenEnvironment::Mock);
12470
12471 // Test mock detection via password
12472 env::set_var("AMP_USERNAME", "real_user");
12473 env::set_var("AMP_PASSWORD", "mock_pass");
12474
12475 let environment = TokenEnvironment::detect();
12476 assert_eq!(environment, TokenEnvironment::Mock);
12477
12478 // Clean up
12479 env::remove_var("AMP_USERNAME");
12480 env::remove_var("AMP_PASSWORD");
12481 }
12482
12483 #[test]
12484 fn test_token_environment_detect_mock_via_base_url() {
12485 // Set up environment for mock detection via localhost URL
12486 env::remove_var("AMP_TESTS");
12487 env::set_var("AMP_USERNAME", "real_user");
12488 env::set_var("AMP_PASSWORD", "real_pass");
12489 env::set_var("AMP_API_BASE_URL", "http://localhost:8080/api");
12490
12491 let environment = TokenEnvironment::detect();
12492 assert_eq!(environment, TokenEnvironment::Mock);
12493
12494 // Test with 127.0.0.1
12495 env::set_var("AMP_API_BASE_URL", "http://127.0.0.1:3000/api");
12496 let environment = TokenEnvironment::detect();
12497 assert_eq!(environment, TokenEnvironment::Mock);
12498
12499 // Test with mock in URL
12500 env::set_var("AMP_API_BASE_URL", "http://mock-server.example.com/api");
12501 let environment = TokenEnvironment::detect();
12502 assert_eq!(environment, TokenEnvironment::Mock);
12503
12504 // Clean up
12505 env::remove_var("AMP_USERNAME");
12506 env::remove_var("AMP_PASSWORD");
12507 env::remove_var("AMP_API_BASE_URL");
12508 }
12509
12510 #[test]
12511 fn test_token_environment_detect_live_via_real_credentials() {
12512 // Set up environment for live detection via real credentials
12513 env::remove_var("AMP_TESTS");
12514 env::set_var("AMP_USERNAME", "real_user");
12515 env::set_var("AMP_PASSWORD", "real_pass");
12516 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
12517
12518 let environment = TokenEnvironment::detect();
12519 assert_eq!(environment, TokenEnvironment::Live);
12520
12521 // Clean up
12522 env::remove_var("AMP_USERNAME");
12523 env::remove_var("AMP_PASSWORD");
12524 env::remove_var("AMP_API_BASE_URL");
12525 }
12526
12527 #[test]
12528 fn test_token_environment_detect_mock_fallback() {
12529 // Set up environment with no credentials (fallback to mock)
12530 env::remove_var("AMP_TESTS");
12531 env::remove_var("AMP_USERNAME");
12532 env::remove_var("AMP_PASSWORD");
12533 env::remove_var("AMP_API_BASE_URL");
12534
12535 let environment = TokenEnvironment::detect();
12536 assert_eq!(environment, TokenEnvironment::Mock);
12537 }
12538
12539 #[test]
12540 fn test_has_mock_credentials() {
12541 // Test mock username detection
12542 assert!(TokenEnvironment::has_mock_credentials(
12543 "mock_user",
12544 "real_pass",
12545 ""
12546 ));
12547 assert!(TokenEnvironment::has_mock_credentials(
12548 "Mock_User",
12549 "real_pass",
12550 ""
12551 ));
12552 assert!(TokenEnvironment::has_mock_credentials(
12553 "user_mock",
12554 "real_pass",
12555 ""
12556 ));
12557
12558 // Test mock password detection
12559 assert!(TokenEnvironment::has_mock_credentials(
12560 "real_user",
12561 "mock_pass",
12562 ""
12563 ));
12564 assert!(TokenEnvironment::has_mock_credentials(
12565 "real_user",
12566 "Mock_Pass",
12567 ""
12568 ));
12569 assert!(TokenEnvironment::has_mock_credentials(
12570 "real_user",
12571 "pass_mock",
12572 ""
12573 ));
12574
12575 // Test mock URL detection
12576 assert!(TokenEnvironment::has_mock_credentials(
12577 "real_user",
12578 "real_pass",
12579 "http://localhost:8080"
12580 ));
12581 assert!(TokenEnvironment::has_mock_credentials(
12582 "real_user",
12583 "real_pass",
12584 "http://127.0.0.1:3000"
12585 ));
12586 assert!(TokenEnvironment::has_mock_credentials(
12587 "real_user",
12588 "real_pass",
12589 "http://mock-server.com"
12590 ));
12591 assert!(TokenEnvironment::has_mock_credentials(
12592 "real_user",
12593 "real_pass",
12594 "http://Mock-Server.com"
12595 ));
12596
12597 // Test non-mock credentials
12598 assert!(!TokenEnvironment::has_mock_credentials(
12599 "real_user",
12600 "real_pass",
12601 "https://amp-test.blockstream.com"
12602 ));
12603 assert!(!TokenEnvironment::has_mock_credentials("", "", ""));
12604 }
12605
12606 #[test]
12607 fn test_token_environment_should_persist_tokens() {
12608 assert!(!TokenEnvironment::Mock.should_persist_tokens());
12609 assert!(TokenEnvironment::Live.should_persist_tokens());
12610
12611 // Auto should delegate to detect()
12612 env::set_var("AMP_TESTS", "live");
12613 assert!(TokenEnvironment::Auto.should_persist_tokens());
12614
12615 env::set_var("AMP_USERNAME", "mock_user");
12616 env::set_var("AMP_PASSWORD", "some_password");
12617 env::remove_var("AMP_TESTS");
12618 env::remove_var("AMP_API_BASE_URL");
12619 assert!(!TokenEnvironment::Auto.should_persist_tokens());
12620
12621 // Clean up
12622 env::remove_var("AMP_USERNAME");
12623 env::remove_var("AMP_PASSWORD");
12624 }
12625
12626 #[test]
12627 fn test_token_environment_is_mock_and_is_live() {
12628 assert!(TokenEnvironment::Mock.is_mock());
12629 assert!(!TokenEnvironment::Mock.is_live());
12630
12631 assert!(!TokenEnvironment::Live.is_mock());
12632 assert!(TokenEnvironment::Live.is_live());
12633
12634 // Auto should delegate to detect()
12635 env::set_var("AMP_USERNAME", "mock_user");
12636 env::set_var("AMP_PASSWORD", "some_password");
12637 env::remove_var("AMP_TESTS");
12638 env::remove_var("AMP_API_BASE_URL");
12639 assert!(TokenEnvironment::Auto.is_mock());
12640 assert!(!TokenEnvironment::Auto.is_live());
12641
12642 env::set_var("AMP_TESTS", "live");
12643 assert!(!TokenEnvironment::Auto.is_mock());
12644 assert!(TokenEnvironment::Auto.is_live());
12645
12646 // Clean up
12647 env::remove_var("AMP_USERNAME");
12648 env::remove_var("AMP_PASSWORD");
12649 env::remove_var("AMP_TESTS");
12650 }
12651
12652 #[tokio::test]
12653 async fn test_token_environment_create_strategy_mock() {
12654 let mock_token = "test_mock_token".to_string();
12655 let strategy = TokenEnvironment::Mock
12656 .create_strategy(Some(mock_token.clone()))
12657 .await
12658 .unwrap();
12659
12660 assert_eq!(strategy.strategy_type(), "mock");
12661 assert!(!strategy.should_persist());
12662
12663 let token = strategy.get_token().await.unwrap();
12664 assert_eq!(token, mock_token);
12665 }
12666
12667 #[tokio::test]
12668 async fn test_token_environment_create_strategy_live() {
12669 let strategy = TokenEnvironment::Live.create_strategy(None).await.unwrap();
12670
12671 assert_eq!(strategy.strategy_type(), "live");
12672 assert!(strategy.should_persist());
12673 }
12674
12675 #[tokio::test]
12676 async fn test_token_environment_create_auto_strategy() {
12677 // Test with mock environment - need both username and password for proper detection
12678 env::set_var("AMP_USERNAME", "mock_user");
12679 env::set_var("AMP_PASSWORD", "some_password");
12680 env::remove_var("AMP_TESTS");
12681 env::remove_var("AMP_API_BASE_URL");
12682
12683 let mock_token = "auto_mock_token".to_string();
12684 let strategy = TokenEnvironment::create_auto_strategy(Some(mock_token.clone()))
12685 .await
12686 .unwrap();
12687
12688 assert_eq!(strategy.strategy_type(), "mock");
12689 let token = strategy.get_token().await.unwrap();
12690 assert_eq!(token, mock_token);
12691
12692 // Clean up
12693 env::remove_var("AMP_USERNAME");
12694 env::remove_var("AMP_PASSWORD");
12695 }
12696
12697 #[tokio::test]
12698 async fn test_mock_token_strategy_factory_methods() {
12699 // Test with_default_token
12700 let strategy = MockTokenStrategy::with_default_token();
12701 assert_eq!(strategy.strategy_type(), "mock");
12702 let token = strategy.get_token().await.unwrap();
12703 assert_eq!(token, "mock_token_default");
12704
12705 // Test for_test
12706 let strategy = MockTokenStrategy::for_test("my_test");
12707 let token = strategy.get_token().await.unwrap();
12708 assert_eq!(token, "mock_token_my_test");
12709 }
12710
12711 #[tokio::test]
12712 async fn test_live_token_strategy_factory_methods() {
12713 // Test for_testing
12714 let strategy = LiveTokenStrategy::for_testing().await.unwrap();
12715 assert_eq!(strategy.strategy_type(), "live");
12716 assert!(strategy.should_persist());
12717 }
12718
12719 #[tokio::test]
12720 async fn test_standalone_factory_functions() {
12721 // Test create_mock_token_strategy
12722 let mock_token = "standalone_mock".to_string();
12723 let strategy = create_mock_token_strategy(mock_token.clone());
12724 assert_eq!(strategy.strategy_type(), "mock");
12725 let token = strategy.get_token().await.unwrap();
12726 assert_eq!(token, mock_token);
12727
12728 // Test create_live_token_strategy
12729 let strategy = create_live_token_strategy().await.unwrap();
12730 assert_eq!(strategy.strategy_type(), "live");
12731
12732 // Test create_auto_token_strategy with mock environment
12733 env::set_var("AMP_USERNAME", "mock_user");
12734 env::set_var("AMP_PASSWORD", "some_password");
12735 env::remove_var("AMP_TESTS");
12736 env::remove_var("AMP_API_BASE_URL");
12737
12738 let auto_mock_token = "auto_standalone_mock".to_string();
12739 let strategy = create_auto_token_strategy(Some(auto_mock_token.clone()))
12740 .await
12741 .unwrap();
12742 assert_eq!(strategy.strategy_type(), "mock");
12743 let token = strategy.get_token().await.unwrap();
12744 assert_eq!(token, auto_mock_token);
12745
12746 // Test create_token_strategy_for_environment
12747 let env_mock_token = "env_mock".to_string();
12748 let strategy = create_token_strategy_for_environment(
12749 TokenEnvironment::Mock,
12750 Some(env_mock_token.clone()),
12751 )
12752 .await
12753 .unwrap();
12754 assert_eq!(strategy.strategy_type(), "mock");
12755 let token = strategy.get_token().await.unwrap();
12756 assert_eq!(token, env_mock_token);
12757
12758 // Clean up
12759 env::remove_var("AMP_USERNAME");
12760 env::remove_var("AMP_PASSWORD");
12761 }
12762
12763 #[test]
12764 fn test_environment_detection_with_various_credential_combinations() {
12765 // Test case 1: AMP_TESTS=live overrides everything
12766 env::set_var("AMP_TESTS", "live");
12767 env::set_var("AMP_USERNAME", "mock_user");
12768 env::set_var("AMP_PASSWORD", "mock_pass");
12769 env::set_var("AMP_API_BASE_URL", "http://localhost:8080");
12770 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Live);
12771
12772 // Test case 2: Mock username with real password and URL
12773 env::remove_var("AMP_TESTS");
12774 env::set_var("AMP_USERNAME", "mock_user");
12775 env::set_var("AMP_PASSWORD", "real_password");
12776 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
12777 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
12778
12779 // Test case 3: Real username with mock password
12780 env::set_var("AMP_USERNAME", "real_user");
12781 env::set_var("AMP_PASSWORD", "mock_password");
12782 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
12783 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
12784
12785 // Test case 4: Real credentials with localhost URL
12786 env::set_var("AMP_USERNAME", "real_user");
12787 env::set_var("AMP_PASSWORD", "real_password");
12788 env::set_var("AMP_API_BASE_URL", "http://localhost:3000/api");
12789 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
12790
12791 // Test case 5: All real credentials
12792 env::set_var("AMP_USERNAME", "real_user");
12793 env::set_var("AMP_PASSWORD", "real_password");
12794 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
12795 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Live);
12796
12797 // Test case 6: Empty credentials
12798 env::remove_var("AMP_USERNAME");
12799 env::remove_var("AMP_PASSWORD");
12800 env::remove_var("AMP_API_BASE_URL");
12801 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
12802
12803 // Test case 7: Only username set
12804 env::set_var("AMP_USERNAME", "real_user");
12805 env::remove_var("AMP_PASSWORD");
12806 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
12807
12808 // Test case 8: Only password set
12809 env::remove_var("AMP_USERNAME");
12810 env::set_var("AMP_PASSWORD", "real_password");
12811 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
12812
12813 // Clean up all environment variables
12814 env::remove_var("AMP_TESTS");
12815 env::remove_var("AMP_USERNAME");
12816 env::remove_var("AMP_PASSWORD");
12817 env::remove_var("AMP_API_BASE_URL");
12818 }
12819
12820 #[tokio::test]
12821 async fn test_distribute_asset_input_validation() {
12822 // Create a mock client for testing
12823 let client = ApiClient::with_mock_token(
12824 reqwest::Url::parse("http://localhost:8080/api").unwrap(),
12825 "test_token".to_string(),
12826 )
12827 .unwrap();
12828
12829 // Test invalid asset UUID
12830 let assignments = vec![AssetDistributionAssignment {
12831 user_id: "user123".to_string(),
12832 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
12833 amount: 100.0,
12834 }];
12835
12836 // Create a mock ElementsRpc (this will fail connection validation, but that's expected)
12837 let elements_rpc = ElementsRpc::new(
12838 "http://localhost:18884".to_string(),
12839 "user".to_string(),
12840 "pass".to_string(),
12841 );
12842
12843 // Create a mock signer
12844 let (_, signer) = LwkSoftwareSigner::generate_new().unwrap();
12845
12846 // Test with invalid UUID format
12847 let result = client
12848 .distribute_asset(
12849 "invalid-uuid",
12850 assignments.clone(),
12851 &elements_rpc,
12852 "test_wallet",
12853 &signer,
12854 )
12855 .await;
12856
12857 assert!(result.is_err());
12858 if let Err(AmpError::Validation(msg)) = result {
12859 assert!(msg.contains("Invalid asset UUID"));
12860 } else {
12861 panic!("Expected validation error for invalid UUID");
12862 }
12863
12864 // Test with empty assignments
12865 let result = client
12866 .distribute_asset(
12867 "550e8400-e29b-41d4-a716-446655440000",
12868 vec![],
12869 &elements_rpc,
12870 "test_wallet",
12871 &signer,
12872 )
12873 .await;
12874
12875 assert!(result.is_err());
12876 if let Err(AmpError::Validation(msg)) = result {
12877 assert!(msg.contains("Invalid assignments"));
12878 } else {
12879 panic!("Expected validation error for empty assignments");
12880 }
12881 }
12882
12883 #[test]
12884 fn test_validate_asset_uuid() {
12885 let _client = ApiClient::with_mock_token(
12886 reqwest::Url::parse("http://localhost:8080/api").unwrap(),
12887 "test_token".to_string(),
12888 )
12889 .unwrap();
12890
12891 // Valid UUID
12892 assert!(ApiClient::validate_asset_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
12893
12894 // Invalid UUIDs
12895 assert!(ApiClient::validate_asset_uuid("").is_err());
12896 assert!(ApiClient::validate_asset_uuid("invalid").is_err());
12897 assert!(ApiClient::validate_asset_uuid("550e8400-e29b-41d4-a716").is_err()); // Too short
12898 assert!(
12899 ApiClient::validate_asset_uuid("550e8400-e29b-41d4-a716-446655440000-extra").is_err()
12900 ); // Too long
12901 assert!(ApiClient::validate_asset_uuid("550e8400xe29bx41d4xa716x446655440000").is_err()); // Wrong separators
12902 assert!(ApiClient::validate_asset_uuid("550e8400-e29g-41d4-a716-446655440000").is_err());
12903 // Invalid hex char
12904 }
12905
12906 #[test]
12907 fn test_validate_assignments() {
12908 let _client = ApiClient::with_mock_token(
12909 reqwest::Url::parse("http://localhost:8080/api").unwrap(),
12910 "test_token".to_string(),
12911 )
12912 .unwrap();
12913
12914 // Valid assignments
12915 let valid_assignments = vec![AssetDistributionAssignment {
12916 user_id: "user123".to_string(),
12917 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
12918 amount: 100.0,
12919 }];
12920 assert!(ApiClient::validate_assignments(&valid_assignments).is_ok());
12921
12922 // Empty assignments
12923 assert!(ApiClient::validate_assignments(&[]).is_err());
12924
12925 // Assignment with empty user_id
12926 let invalid_assignments = vec![AssetDistributionAssignment {
12927 user_id: "".to_string(),
12928 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
12929 amount: 100.0,
12930 }];
12931 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
12932
12933 // Assignment with empty address
12934 let invalid_assignments = vec![AssetDistributionAssignment {
12935 user_id: "user123".to_string(),
12936 address: "".to_string(),
12937 amount: 100.0,
12938 }];
12939 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
12940
12941 // Assignment with invalid address format
12942 let invalid_assignments = vec![AssetDistributionAssignment {
12943 user_id: "user123".to_string(),
12944 address: "invalid_address".to_string(),
12945 amount: 100.0,
12946 }];
12947 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
12948
12949 // Assignment with non-positive amount
12950 let invalid_assignments = vec![AssetDistributionAssignment {
12951 user_id: "user123".to_string(),
12952 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
12953 amount: 0.0,
12954 }];
12955 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
12956
12957 // Assignment with unreasonably large amount
12958 let invalid_assignments = vec![AssetDistributionAssignment {
12959 user_id: "user123".to_string(),
12960 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
12961 amount: 25_000_000.0,
12962 }];
12963 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
12964 }
12965
12966 #[test]
12967 fn test_enhanced_error_handling_and_logging() {
12968 // Test AmpError creation and context enhancement
12969 let api_error = AmpError::api("Distribution creation failed");
12970 let contextual_error = api_error.with_context("Step 6: Distribution creation");
12971
12972 match contextual_error {
12973 AmpError::Api(msg) => {
12974 assert!(msg.contains("Step 6: Distribution creation"));
12975 assert!(msg.contains("Distribution creation failed"));
12976 }
12977 _ => panic!("Expected Api error variant"),
12978 }
12979
12980 // Test retry instructions for different error types
12981 let rpc_error = AmpError::rpc("Connection failed");
12982 assert!(rpc_error.is_retryable());
12983 assert!(rpc_error.retry_instructions().is_some());
12984 assert!(rpc_error
12985 .retry_instructions()
12986 .unwrap()
12987 .contains("Elements node"));
12988
12989 let validation_error = AmpError::validation("Invalid UUID");
12990 assert!(!validation_error.is_retryable());
12991 assert!(validation_error.retry_instructions().is_none());
12992
12993 let timeout_error = AmpError::timeout("Confirmation timeout for txid abc123");
12994 assert!(!timeout_error.is_retryable());
12995 let instructions = timeout_error.retry_instructions();
12996 assert!(instructions.is_some());
12997 assert!(instructions.unwrap().contains("transaction ID"));
12998
12999 // Test error helper methods
13000 let signer_error =
13001 AmpError::Signer(crate::signer::SignerError::Lwk("Test error".to_string()));
13002 assert!(!signer_error.is_retryable());
13003 assert!(signer_error.retry_instructions().is_none());
13004
13005 // Test serialization error
13006 let json_error = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
13007 let serialization_error = AmpError::from(json_error);
13008 assert!(matches!(serialization_error, AmpError::Serialization(_)));
13009 assert!(!serialization_error.is_retryable());
13010 }
13011}