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, GaidRequest, IssuanceRequest, IssuanceResponse, Outpoint, Ownership,
25 Password, TokenData, TokenInfo, TokenRequest, TokenResponse, TransactionDetail, TxInput,
26 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("Failed to parse AMP response: {0}")]
411 ResponseParsingFailed(String),
412 #[error("AMP token request failed with status {status}: {error_text}")]
413 TokenRequestFailed {
414 status: reqwest::StatusCode,
415 error_text: String,
416 },
417 #[error("Failed to parse url: {0}")]
418 UrlParse(#[from] url::ParseError),
419 #[error("Reqwest error: {0}")]
420 Reqwest(#[from] reqwest::Error),
421 #[error("Invalid retry configuration: {0}")]
422 InvalidRetryConfig(String),
423 #[error("Token management error: {0}")]
424 Token(#[from] TokenError),
425}
426
427/// Enhanced error enum for distribution operations and `ElementsRpc`
428#[derive(Error, Debug)]
429pub enum AmpError {
430 #[error("API error: {0}")]
431 Api(String),
432
433 #[error("RPC error: {0}")]
434 Rpc(String),
435
436 #[error("Signer error: {0}")]
437 Signer(#[from] SignerError),
438
439 #[error("Timeout waiting for confirmations: {0}")]
440 Timeout(String),
441
442 #[error("Validation error: {0}")]
443 Validation(String),
444
445 #[error("Network error: {0}")]
446 Network(#[from] reqwest::Error),
447
448 #[error("Serialization error: {0}")]
449 Serialization(#[from] serde_json::Error),
450
451 #[error(transparent)]
452 Existing(#[from] Error),
453}
454
455impl AmpError {
456 /// Creates a new API error
457 pub fn api<S: Into<String>>(message: S) -> Self {
458 Self::Api(message.into())
459 }
460
461 /// Creates a new RPC error
462 pub fn rpc<S: Into<String>>(message: S) -> Self {
463 Self::Rpc(message.into())
464 }
465
466 /// Creates a new timeout error
467 pub fn timeout<S: Into<String>>(message: S) -> Self {
468 Self::Timeout(message.into())
469 }
470
471 /// Creates a new validation error
472 pub fn validation<S: Into<String>>(message: S) -> Self {
473 Self::Validation(message.into())
474 }
475
476 /// Adds context to an error
477 #[must_use]
478 pub fn with_context<S: Into<String>>(self, context: S) -> Self {
479 let context_str = context.into();
480 match self {
481 Self::Api(msg) => Self::Api(format!("{context_str}: {msg}")),
482 Self::Rpc(msg) => Self::Rpc(format!("{context_str}: {msg}")),
483 Self::Timeout(msg) => Self::Timeout(format!("{context_str}: {msg}")),
484 Self::Validation(msg) => Self::Validation(format!("{context_str}: {msg}")),
485 other => other, // Don't modify other error types
486 }
487 }
488
489 /// Returns true if this error indicates a retryable condition
490 #[must_use]
491 pub const fn is_retryable(&self) -> bool {
492 match self {
493 Self::Network(_) | Self::Rpc(_) => true, // RPC errors might be transient
494 Self::Existing(Error::Token(token_err)) => token_err.is_retryable(),
495 _ => false,
496 }
497 }
498
499 /// Provides user-friendly retry instructions when applicable
500 #[must_use]
501 pub fn retry_instructions(&self) -> Option<String> {
502 match self {
503 Self::Network(_) => Some("Check network connection and retry".to_string()),
504 Self::Rpc(_) => Some("Check Elements node connection and retry".to_string()),
505 Self::Timeout(msg) if msg.contains("txid") => {
506 Some("Use the transaction ID to manually confirm the distribution".to_string())
507 }
508 Self::Existing(Error::Token(TokenError::RateLimited {
509 retry_after_seconds,
510 })) => Some(format!(
511 "Rate limited. Retry after {retry_after_seconds} seconds"
512 )),
513 _ => None,
514 }
515 }
516}
517
518/// Detailed error types for token management operations
519#[derive(Error, Debug, Clone, PartialEq, Eq)]
520pub enum TokenError {
521 #[error("Token refresh failed: {0}")]
522 RefreshFailed(String),
523 #[error("Token obtain failed after {attempts} attempts: {last_error}")]
524 ObtainFailed { attempts: u32, last_error: String },
525 #[error("Rate limited: retry after {retry_after_seconds} seconds")]
526 RateLimited { retry_after_seconds: u64 },
527 #[error("Request timeout after {timeout_seconds} seconds")]
528 Timeout { timeout_seconds: u64 },
529 #[error("Serialization error: {0}")]
530 Serialization(String),
531 #[error("Token storage error: {0}")]
532 Storage(String),
533 #[error("Token validation error: {0}")]
534 Validation(String),
535}
536
537impl TokenError {
538 /// Creates a new `RefreshFailed` error
539 #[must_use]
540 pub fn refresh_failed<S: Into<String>>(message: S) -> Self {
541 Self::RefreshFailed(message.into())
542 }
543
544 /// Creates a new `ObtainFailed` error
545 #[must_use]
546 pub const fn obtain_failed(attempts: u32, last_error: String) -> Self {
547 Self::ObtainFailed {
548 attempts,
549 last_error,
550 }
551 }
552
553 /// Creates a new `RateLimited` error
554 #[must_use]
555 pub const fn rate_limited(retry_after_seconds: u64) -> Self {
556 Self::RateLimited {
557 retry_after_seconds,
558 }
559 }
560
561 /// Creates a new Timeout error
562 #[must_use]
563 pub const fn timeout(timeout_seconds: u64) -> Self {
564 Self::Timeout { timeout_seconds }
565 }
566
567 /// Creates a new Serialization error
568 #[must_use]
569 pub fn serialization<S: Into<String>>(message: S) -> Self {
570 Self::Serialization(message.into())
571 }
572
573 /// Creates a new Storage error
574 #[must_use]
575 pub fn storage<S: Into<String>>(message: S) -> Self {
576 Self::Storage(message.into())
577 }
578
579 /// Creates a new Validation error
580 #[must_use]
581 pub fn validation<S: Into<String>>(message: S) -> Self {
582 Self::Validation(message.into())
583 }
584
585 /// Returns true if this error indicates a retryable condition
586 #[must_use]
587 pub const fn is_retryable(&self) -> bool {
588 matches!(
589 self,
590 Self::RefreshFailed(_) | Self::RateLimited { .. } | Self::Timeout { .. }
591 )
592 }
593
594 /// Returns true if this error indicates a rate limiting condition
595 #[must_use]
596 pub const fn is_rate_limited(&self) -> bool {
597 matches!(self, Self::RateLimited { .. })
598 }
599
600 /// Returns the retry delay in seconds if this is a rate limited error
601 #[must_use]
602 pub const fn retry_after_seconds(&self) -> Option<u64> {
603 match self {
604 Self::RateLimited {
605 retry_after_seconds,
606 } => Some(*retry_after_seconds),
607 _ => None,
608 }
609 }
610}
611
612// Conversion from serde_json::Error for serialization errors
613impl From<serde_json::Error> for TokenError {
614 fn from(err: serde_json::Error) -> Self {
615 Self::Serialization(err.to_string())
616 }
617}
618
619#[cfg(test)]
620mod amp_error_tests {
621 use super::*;
622
623 #[test]
624 fn test_amp_error_creation_helpers() {
625 let api_error = AmpError::api("Failed to create distribution");
626 assert!(matches!(api_error, AmpError::Api(_)));
627
628 let rpc_error = AmpError::rpc("Elements node connection failed");
629 assert!(matches!(rpc_error, AmpError::Rpc(_)));
630
631 let validation_error = AmpError::validation("Invalid asset UUID format");
632 assert!(matches!(validation_error, AmpError::Validation(_)));
633
634 let timeout_error = AmpError::timeout("Confirmation timeout");
635 assert!(matches!(timeout_error, AmpError::Timeout(_)));
636 }
637
638 #[test]
639 fn test_amp_error_with_context() {
640 let api_error = AmpError::api("Failed to create distribution");
641 let contextual_error = api_error.with_context("During distribution creation");
642
643 match contextual_error {
644 AmpError::Api(msg) => {
645 assert!(msg.contains("During distribution creation"));
646 assert!(msg.contains("Failed to create distribution"));
647 }
648 _ => panic!("Expected Api error variant"),
649 }
650
651 // Test that context doesn't modify errors that already have good context
652 let signer_error = AmpError::Signer(SignerError::Lwk("Test error".to_string()));
653 let contextual_signer = signer_error.with_context("Additional context");
654 assert!(matches!(contextual_signer, AmpError::Signer(_)));
655 }
656
657 #[test]
658 fn test_amp_error_retryability() {
659 let api_error = AmpError::api("Failed to create distribution");
660 assert!(!api_error.is_retryable());
661
662 let rpc_error = AmpError::rpc("Elements node connection failed");
663 assert!(rpc_error.is_retryable());
664
665 let validation_error = AmpError::validation("Invalid asset UUID format");
666 assert!(!validation_error.is_retryable());
667
668 let timeout_error = AmpError::timeout("Confirmation timeout");
669 assert!(!timeout_error.is_retryable());
670
671 let signer_error = AmpError::Signer(SignerError::Lwk("Test error".to_string()));
672 assert!(!signer_error.is_retryable());
673 }
674
675 #[test]
676 fn test_amp_error_retry_instructions() {
677 let rpc_error = AmpError::rpc("Elements node connection failed");
678 let instructions = rpc_error.retry_instructions();
679 assert!(instructions.is_some());
680 assert!(instructions.unwrap().contains("Elements node"));
681
682 let validation_error = AmpError::validation("Invalid asset UUID format");
683 assert!(validation_error.retry_instructions().is_none());
684
685 let timeout_with_txid = AmpError::timeout("Confirmation timeout for txid abc123");
686 let timeout_instructions = timeout_with_txid.retry_instructions();
687 assert!(timeout_instructions.is_some());
688 assert!(timeout_instructions.unwrap().contains("transaction ID"));
689 }
690
691 #[test]
692 fn test_amp_error_display() {
693 let api_error = AmpError::api("Test API error");
694 assert_eq!(format!("{}", api_error), "API error: Test API error");
695
696 let rpc_error = AmpError::rpc("Test RPC error");
697 assert_eq!(format!("{}", rpc_error), "RPC error: Test RPC error");
698
699 let validation_error = AmpError::validation("Test validation error");
700 assert_eq!(
701 format!("{}", validation_error),
702 "Validation error: Test validation error"
703 );
704
705 let timeout_error = AmpError::timeout("Test timeout error");
706 assert_eq!(
707 format!("{}", timeout_error),
708 "Timeout waiting for confirmations: Test timeout error"
709 );
710 }
711
712 #[test]
713 fn test_amp_error_from_conversions() {
714 // Test conversion from SignerError
715 let signer_error = SignerError::Lwk("Test LWK error".to_string());
716 let amp_error = AmpError::from(signer_error);
717 assert!(matches!(amp_error, AmpError::Signer(_)));
718
719 // Test conversion from existing Error
720 let existing_error = Error::MissingEnvVar("TEST_VAR".to_string());
721 let amp_error = AmpError::from(existing_error);
722 assert!(matches!(amp_error, AmpError::Existing(_)));
723
724 // Test conversion from serde_json::Error
725 let json_error = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
726 let amp_error = AmpError::from(json_error);
727 assert!(matches!(amp_error, AmpError::Serialization(_)));
728 }
729}
730
731/// Elements RPC client for blockchain operations
732#[derive(Debug)]
733pub struct ElementsRpc {
734 client: reqwest::Client,
735 base_url: String,
736 username: String,
737 password: String,
738}
739
740/// Network information from Elements node
741#[derive(Debug, serde::Deserialize)]
742pub struct NetworkInfo {
743 pub version: i64,
744 pub subversion: String,
745 pub protocolversion: i64,
746 pub localservices: String,
747 pub localrelay: bool,
748 pub timeoffset: i64,
749 pub networkactive: bool,
750 pub connections: i64,
751 pub networks: Vec<serde_json::Value>,
752 pub relayfee: f64,
753 pub incrementalfee: f64,
754 pub localaddresses: Vec<serde_json::Value>,
755 pub warnings: String,
756}
757
758/// Blockchain information from Elements node
759#[derive(Debug, serde::Deserialize)]
760pub struct BlockchainInfo {
761 pub chain: String,
762 pub blocks: i64,
763 pub headers: i64,
764 pub bestblockhash: String,
765 #[serde(default)]
766 pub difficulty: Option<f64>,
767 #[serde(default)]
768 pub mediantime: Option<i64>,
769 #[serde(default)]
770 pub verificationprogress: Option<f64>,
771 #[serde(default)]
772 pub initialblockdownload: Option<bool>,
773 #[serde(default)]
774 pub chainwork: Option<String>,
775 #[serde(default)]
776 pub size_on_disk: Option<i64>,
777 #[serde(default)]
778 pub pruned: Option<bool>,
779 #[serde(default)]
780 pub softforks: Option<serde_json::Value>,
781 #[serde(default)]
782 pub warnings: Option<String>,
783}
784
785/// RPC request structure for Elements node
786#[derive(Debug, serde::Serialize)]
787struct RpcRequest {
788 jsonrpc: String,
789 id: String,
790 method: String,
791 params: serde_json::Value,
792}
793
794/// RPC response structure from Elements node
795#[derive(Debug, serde::Deserialize)]
796struct RpcResponse<T> {
797 #[allow(dead_code)]
798 jsonrpc: Option<String>, // Optional for JSON-RPC 1.0 compatib
799 #[allow(dead_code)]
800 id: String,
801 result: Option<T>,
802 error: Option<RpcError>,
803}
804
805/// RPC error structure from Elements node
806#[derive(Debug, serde::Deserialize)]
807struct RpcError {
808 code: i32,
809 message: String,
810}
811
812impl ElementsRpc {
813 /// Creates a new `ElementsRpc` client with connection parameters
814 ///
815 /// # Arguments
816 /// * `url` - The RPC endpoint URL (e.g., <http://localhost:18884>)
817 /// * `username` - RPC authentication username
818 /// * `password` - RPC authentication password
819 ///
820 /// # Examples
821 /// ```
822 /// use amp_rs::ElementsRpc;
823 ///
824 /// let rpc = ElementsRpc::new(
825 /// "http://localhost:18884".to_string(),
826 /// "user".to_string(),
827 /// "pass".to_string()
828 /// );
829 /// ```
830 /// # Panics
831 ///
832 /// Panics if the HTTP client cannot be created.
833 #[must_use]
834 pub fn new(url: String, username: String, password: String) -> Self {
835 let client = reqwest::Client::builder()
836 .timeout(std::time::Duration::from_secs(30))
837 .build()
838 .expect("Failed to create HTTP client");
839
840 Self {
841 client,
842 base_url: url,
843 username,
844 password,
845 }
846 }
847
848 /// Creates a new `ElementsRpc` client from environment variables
849 ///
850 /// Expected environment variables:
851 /// - `ELEMENTS_RPC_URL`: RPC endpoint URL
852 /// - `ELEMENTS_RPC_USER`: RPC username
853 /// - `ELEMENTS_RPC_PASSWORD`: RPC password
854 ///
855 /// # Errors
856 /// Returns an error if any required environment variable is missing
857 ///
858 /// # Examples
859 /// ```no_run
860 /// use amp_rs::ElementsRpc;
861 ///
862 /// let rpc = ElementsRpc::from_env().unwrap();
863 /// ```
864 pub fn from_env() -> Result<Self, AmpError> {
865 let url = env::var("ELEMENTS_RPC_URL")
866 .map_err(|_| AmpError::validation("Missing ELEMENTS_RPC_URL environment variable"))?;
867 let username = env::var("ELEMENTS_RPC_USER")
868 .map_err(|_| AmpError::validation("Missing ELEMENTS_RPC_USER environment variable"))?;
869 let password = env::var("ELEMENTS_RPC_PASSWORD").map_err(|_| {
870 AmpError::validation("Missing ELEMENTS_RPC_PASSWORD environment variable")
871 })?;
872
873 Ok(Self::new(url, username, password))
874 }
875
876 /// Makes an RPC call to the Elements node
877 ///
878 /// # Arguments
879 /// * `method` - The RPC method name
880 /// * `params` - The parameters for the RPC call
881 ///
882 /// # Errors
883 /// Returns an error if the RPC call fails or returns an error
884 async fn rpc_call<T: serde::de::DeserializeOwned>(
885 &self,
886 method: &str,
887 params: serde_json::Value,
888 ) -> Result<T, AmpError> {
889 tracing::debug!("Making RPC call: {} with params: {:?}", method, params);
890
891 let request = RpcRequest {
892 jsonrpc: "1.0".to_string(),
893 id: "amp-client".to_string(),
894 method: method.to_string(),
895 params,
896 };
897
898 let response = self
899 .client
900 .post(&self.base_url)
901 .basic_auth(&self.username, Some(&self.password))
902 .json(&request)
903 .send()
904 .await
905 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
906
907 if !response.status().is_success() {
908 let status = response.status();
909 let error_body = response
910 .text()
911 .await
912 .unwrap_or_else(|_| "Unable to read error body".to_string());
913 return Err(AmpError::rpc(format!(
914 "RPC request failed with status: {status} - Body: {error_body}"
915 )));
916 }
917
918 let rpc_response: RpcResponse<T> = response
919 .json()
920 .await
921 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
922
923 if let Some(error) = rpc_response.error {
924 return Err(AmpError::rpc(format!(
925 "RPC error {}: {}",
926 error.code, error.message
927 )));
928 }
929
930 rpc_response
931 .result
932 .ok_or_else(|| AmpError::rpc("RPC response missing result field".to_string()))
933 }
934
935 /// Retrieves network information from the Elements node
936 ///
937 /// # Errors
938 /// Returns an error if the RPC call fails
939 ///
940 /// # Examples
941 /// ```no_run
942 /// # use amp_rs::ElementsRpc;
943 /// # #[tokio::main]
944 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
945 /// let rpc = ElementsRpc::from_env()?;
946 /// let network_info = rpc.get_network_info().await?;
947 /// println!("Node version: {}", network_info.version);
948 /// # Ok(())
949 /// # }
950 /// ```
951 pub async fn get_network_info(&self) -> Result<NetworkInfo, AmpError> {
952 self.rpc_call("getnetworkinfo", serde_json::Value::Array(vec![]))
953 .await
954 }
955
956 /// Retrieves blockchain information from the Elements node
957 ///
958 /// # Errors
959 /// Returns an error if the RPC call fails
960 ///
961 /// # Examples
962 /// ```no_run
963 /// # use amp_rs::ElementsRpc;
964 /// # #[tokio::main]
965 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
966 /// let rpc = ElementsRpc::from_env()?;
967 /// let blockchain_info = rpc.get_blockchain_info().await?;
968 /// println!("Current block height: {}", blockchain_info.blocks);
969 /// # Ok(())
970 /// # }
971 /// ```
972 pub async fn get_blockchain_info(&self) -> Result<BlockchainInfo, AmpError> {
973 self.rpc_call("getblockchaininfo", serde_json::Value::Array(vec![]))
974 .await
975 }
976
977 /// Unlocks the wallet with a passphrase for the specified timeout
978 ///
979 /// # Arguments
980 /// * `passphrase` - The wallet passphrase
981 /// * `timeout` - Timeout in seconds for the unlock
982 ///
983 /// # Errors
984 /// Returns an error if the RPC call fails
985 ///
986 /// # Examples
987 /// ```no_run
988 /// # use amp_rs::ElementsRpc;
989 /// # #[tokio::main]
990 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
991 /// let rpc = ElementsRpc::from_env()?;
992 /// rpc.wallet_passphrase("my_passphrase", 300).await?;
993 /// # Ok(())
994 /// # }
995 /// ```
996 pub async fn wallet_passphrase(&self, passphrase: &str, timeout: u64) -> Result<(), AmpError> {
997 let params = serde_json::json!([passphrase, timeout]);
998
999 // wallet_passphrase returns null on success, so we need to handle this specially
1000 let request = RpcRequest {
1001 jsonrpc: "1.0".to_string(),
1002 id: "amp-client".to_string(),
1003 method: "walletpassphrase".to_string(),
1004 params,
1005 };
1006
1007 let response = self
1008 .client
1009 .post(&self.base_url)
1010 .basic_auth(&self.username, Some(&self.password))
1011 .json(&request)
1012 .send()
1013 .await
1014 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1015
1016 if !response.status().is_success() {
1017 return Err(AmpError::rpc(format!(
1018 "RPC request failed with status: {}",
1019 response.status()
1020 )));
1021 }
1022
1023 let rpc_response: RpcResponse<serde_json::Value> = response
1024 .json()
1025 .await
1026 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1027
1028 if let Some(error) = rpc_response.error {
1029 return Err(AmpError::rpc(format!(
1030 "RPC error {}: {}",
1031 error.code, error.message
1032 )));
1033 }
1034
1035 // For wallet_passphrase, null result is success
1036 Ok(())
1037 }
1038
1039 /// Validates the connection to the Elements node
1040 ///
1041 /// This method performs basic connectivity and authentication checks by
1042 /// retrieving network information from the node.
1043 ///
1044 /// # Errors
1045 /// Returns an error if the connection validation fails
1046 ///
1047 /// # Examples
1048 /// ```no_run
1049 /// # use amp_rs::ElementsRpc;
1050 /// # #[tokio::main]
1051 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1052 /// let rpc = ElementsRpc::from_env()?;
1053 /// rpc.validate_connection().await?;
1054 /// println!("Connection to Elements node is valid");
1055 /// # Ok(())
1056 /// # }
1057 /// ```
1058 pub async fn validate_connection(&self) -> Result<(), AmpError> {
1059 tracing::info!(
1060 "Validating connection to Elements node at {}",
1061 self.base_url
1062 );
1063
1064 let network_info = self
1065 .get_network_info()
1066 .await
1067 .map_err(|e| e.with_context("Failed to validate Elements node connection"))?;
1068
1069 tracing::info!(
1070 "Successfully connected to Elements node - Version: {}, Connections: {}",
1071 network_info.version,
1072 network_info.connections
1073 );
1074
1075 Ok(())
1076 }
1077
1078 /// Retrieves comprehensive node status including network and blockchain information
1079 ///
1080 /// This method combines network and blockchain information to provide a complete
1081 /// status overview of the Elements node.
1082 ///
1083 /// # Errors
1084 /// Returns an error if any RPC call fails
1085 ///
1086 /// # Examples
1087 /// ```no_run
1088 /// # use amp_rs::ElementsRpc;
1089 /// # #[tokio::main]
1090 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1091 /// let rpc = ElementsRpc::from_env()?;
1092 /// let (network_info, blockchain_info) = rpc.get_node_status().await?;
1093 /// println!("Node version: {}, Block height: {}", network_info.version, blockchain_info.blocks);
1094 /// # Ok(())
1095 /// # }
1096 /// ```
1097 pub async fn get_node_status(&self) -> Result<(NetworkInfo, BlockchainInfo), AmpError> {
1098 let network_info = self.get_network_info().await?;
1099 let blockchain_info = self.get_blockchain_info().await?;
1100
1101 Ok((network_info, blockchain_info))
1102 }
1103
1104 /// Lists unspent transaction outputs (UTXOs) for a specific asset
1105 ///
1106 /// # Arguments
1107 /// * `asset_id` - Optional asset ID to filter UTXOs. If None, returns all UTXOs
1108 ///
1109 /// # Errors
1110 /// Returns an error if the RPC call fails
1111 ///
1112 /// # Panics
1113 /// May panic if `asset_id` is `Some` but the warning log message attempts to unwrap it.
1114 /// This is a known logging issue and does not affect normal operation.
1115 ///
1116 /// # Examples
1117 /// ```no_run
1118 /// # use amp_rs::ElementsRpc;
1119 /// # #[tokio::main]
1120 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1121 /// let rpc = ElementsRpc::from_env()?;
1122 /// let utxos = rpc.list_unspent(Some("asset_id_hex")).await?;
1123 /// println!("Found {} UTXOs", utxos.len());
1124 /// # Ok(())
1125 /// # }
1126 /// ```
1127 pub async fn list_unspent(&self, asset_id: Option<&str>) -> Result<Vec<Unspent>, AmpError> {
1128 tracing::debug!("Listing unspent outputs for asset: {:?}", asset_id);
1129
1130 let params = asset_id.map_or_else(
1131 || serde_json::json!([1, 9_999_999, [], true]),
1132 |asset| serde_json::json!([1, 9_999_999, [], true, {"asset": asset}]),
1133 );
1134
1135 let utxos: Vec<Unspent> = self
1136 .rpc_call("listunspent", params)
1137 .await
1138 .map_err(|e| {
1139 if let Some(asset) = asset_id {
1140 e.with_context(format!(
1141 "Failed to list unspent outputs for asset {asset}. \
1142 This may indicate that the treasury address is not imported in the Elements node. \
1143 Ensure the treasury address is properly imported as a watch-only address."
1144 ))
1145 } else {
1146 e.with_context("Failed to list unspent outputs")
1147 }
1148 })?;
1149
1150 tracing::debug!("Found {} unspent outputs", utxos.len());
1151
1152 // If we're looking for a specific asset and found no UTXOs, provide helpful context
1153 if utxos.is_empty() && asset_id.is_some() {
1154 tracing::warn!(
1155 "No UTXOs found for asset {}. This may indicate:\n\
1156 1. The treasury address is not imported in the Elements node\n\
1157 2. The asset issuance transaction hasn't been confirmed yet\n\
1158 3. The UTXOs have already been spent",
1159 asset_id.unwrap()
1160 );
1161 }
1162
1163 Ok(utxos)
1164 }
1165
1166 /// List unspent outputs for a specific wallet
1167 ///
1168 /// This method lists unspent transaction outputs (UTXOs) for a specific wallet,
1169 /// optionally filtered by asset ID.
1170 ///
1171 /// # Arguments
1172 ///
1173 /// * `wallet_name` - Name of the Elements wallet to query
1174 /// * `asset_id` - Optional asset ID to filter UTXOs by
1175 ///
1176 /// # Returns
1177 ///
1178 /// Returns a vector of unspent outputs
1179 ///
1180 /// # Errors
1181 /// Returns an error if the RPC call fails or the wallet cannot be loaded
1182 ///
1183 /// # Panics
1184 /// May panic when processing UTXO blinding data if scriptpubkey is unexpectedly missing.
1185 /// This should not occur under normal operation with valid Elements node responses.
1186 ///
1187 /// # Example
1188 ///
1189 /// ```no_run
1190 /// # use amp_rs::ElementsRpc;
1191 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1192 /// let rpc = ElementsRpc::from_env()?;
1193 /// // Note: This would need to be called in an async context
1194 /// // let utxos = rpc.list_unspent_for_wallet("test_wallet", None).await?;
1195 /// // println!("Found {} UTXOs", utxos.len());
1196 /// # Ok(())
1197 /// # }
1198 /// ```
1199 #[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
1200 pub async fn list_unspent_for_wallet(
1201 &self,
1202 wallet_name: &str,
1203 asset_id: Option<&str>,
1204 ) -> Result<Vec<Unspent>, AmpError> {
1205 tracing::debug!(
1206 "Listing unspent outputs for wallet {} and asset: {:?}",
1207 wallet_name,
1208 asset_id
1209 );
1210
1211 // First load the wallet to ensure it's available
1212 self.load_wallet(wallet_name).await?;
1213
1214 let params = asset_id.map_or_else(
1215 || serde_json::json!([1, 9_999_999, [], true]),
1216 |asset| serde_json::json!([1, 9_999_999, [], true, {"asset": asset}]),
1217 );
1218
1219 let request = RpcRequest {
1220 jsonrpc: "1.0".to_string(),
1221 id: "amp-client".to_string(),
1222 method: "listunspent".to_string(),
1223 params,
1224 };
1225
1226 // Use the wallet-specific RPC endpoint
1227 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1228
1229 let response = self
1230 .client
1231 .post(&wallet_url)
1232 .basic_auth(&self.username, Some(&self.password))
1233 .json(&request)
1234 .send()
1235 .await
1236 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1237
1238 if !response.status().is_success() {
1239 let status = response.status();
1240 let error_body = response
1241 .text()
1242 .await
1243 .unwrap_or_else(|_| "Unable to read error body".to_string());
1244 return Err(AmpError::rpc(format!(
1245 "RPC request failed with status: {status} - Body: {error_body}"
1246 )));
1247 }
1248
1249 let rpc_response: RpcResponse<Vec<Unspent>> = response
1250 .json()
1251 .await
1252 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1253
1254 if let Some(error) = rpc_response.error {
1255 return Err(AmpError::rpc(format!(
1256 "RPC error listing unspent outputs: {} (code: {})",
1257 error.message, error.code
1258 )));
1259 }
1260
1261 let mut utxos = rpc_response.result.unwrap_or_default();
1262
1263 // Enrich UTXOs with scriptpubkey information if missing
1264 for utxo in &mut utxos {
1265 if utxo.scriptpubkey.is_none() {
1266 tracing::debug!(
1267 "UTXO {}:{} missing scriptpubkey, attempting to derive from address",
1268 utxo.txid,
1269 utxo.vout
1270 );
1271
1272 // Try to derive scriptpubkey from the address
1273 if let Ok(address) = elements::Address::from_str(&utxo.address) {
1274 let script_pubkey = address.script_pubkey();
1275 utxo.scriptpubkey = Some(hex::encode(script_pubkey.as_bytes()));
1276 tracing::info!(
1277 "Derived scriptpubkey for UTXO {}:{} from address {}: {}",
1278 utxo.txid,
1279 utxo.vout,
1280 utxo.address,
1281 utxo.scriptpubkey.as_ref().unwrap()
1282 );
1283 } else {
1284 tracing::error!(
1285 "Failed to parse address {} for UTXO {}:{}",
1286 utxo.address,
1287 utxo.txid,
1288 utxo.vout
1289 );
1290
1291 // Fallback: try to get transaction details
1292 match self.get_transaction(&utxo.txid).await {
1293 Ok(tx_detail) => {
1294 tracing::debug!(
1295 "Retrieved transaction details for {} as fallback",
1296 utxo.txid
1297 );
1298 // Parse the transaction hex to extract the scriptpubkey for this output
1299 match hex::decode(&tx_detail.hex) {
1300 Ok(tx_bytes) => {
1301 match elements::Transaction::consensus_decode(&tx_bytes[..]) {
1302 Ok(tx) => {
1303 if let Some(output) = tx.output.get(utxo.vout as usize)
1304 {
1305 utxo.scriptpubkey = Some(hex::encode(
1306 output.script_pubkey.as_bytes(),
1307 ));
1308 tracing::info!("Enriched UTXO {}:{} with scriptpubkey from transaction: {}",
1309 utxo.txid, utxo.vout, utxo.scriptpubkey.as_ref().unwrap());
1310 } else {
1311 tracing::error!(
1312 "Output {} not found in transaction {}",
1313 utxo.vout,
1314 utxo.txid
1315 );
1316 }
1317 }
1318 Err(e) => {
1319 tracing::error!(
1320 "Failed to decode transaction {}: {}",
1321 utxo.txid,
1322 e
1323 );
1324 }
1325 }
1326 }
1327 Err(e) => {
1328 tracing::error!(
1329 "Failed to decode hex for transaction {}: {}",
1330 utxo.txid,
1331 e
1332 );
1333 }
1334 }
1335 }
1336 Err(e) => {
1337 tracing::error!(
1338 "Failed to get transaction details for {}: {}",
1339 utxo.txid,
1340 e
1341 );
1342 }
1343 }
1344 }
1345 } else {
1346 tracing::debug!("UTXO {}:{} already has scriptpubkey", utxo.txid, utxo.vout);
1347 }
1348 }
1349
1350 tracing::debug!(
1351 "Found {} unspent outputs for wallet {}",
1352 utxos.len(),
1353 wallet_name
1354 );
1355
1356 // If we're looking for a specific asset and found no UTXOs, provide helpful context
1357 if utxos.is_empty() && asset_id.is_some() {
1358 tracing::warn!(
1359 "No UTXOs found for asset {} in wallet {}. This may indicate:\n\
1360 1. The asset issuance transaction hasn't been confirmed yet\n\
1361 2. The UTXOs have already been spent\n\
1362 3. The wallet doesn't contain the expected addresses",
1363 asset_id.unwrap(),
1364 wallet_name
1365 );
1366 }
1367
1368 Ok(utxos)
1369 }
1370
1371 /// Creates a raw transaction with the specified inputs and outputs
1372 ///
1373 /// # Arguments
1374 /// * `inputs` - Vector of transaction inputs (UTXOs to spend)
1375 /// * `outputs` - Map of addresses to amounts for regular outputs
1376 /// * `assets` - Map of addresses to asset IDs for Liquid-specific outputs
1377 ///
1378 /// # Errors
1379 /// Returns an error if the RPC call fails or transaction creation fails
1380 ///
1381 /// # Examples
1382 /// ```no_run
1383 /// # use amp_rs::{ElementsRpc, model::{TxInput}};
1384 /// # use std::collections::HashMap;
1385 /// # #[tokio::main]
1386 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1387 /// let rpc = ElementsRpc::from_env()?;
1388 /// let inputs = vec![TxInput {
1389 /// txid: "abc123".to_string(),
1390 /// vout: 0,
1391 /// sequence: None,
1392 /// }];
1393 /// let mut outputs = HashMap::new();
1394 /// outputs.insert("address1".to_string(), 100.0);
1395 /// let mut assets = HashMap::new();
1396 /// assets.insert("address1".to_string(), "asset_id".to_string());
1397 /// let raw_tx = rpc.create_raw_transaction(inputs, outputs, assets).await?;
1398 /// # Ok(())
1399 /// # }
1400 /// ```
1401 #[allow(clippy::cognitive_complexity)]
1402 pub async fn create_raw_transaction(
1403 &self,
1404 inputs: Vec<TxInput>,
1405 outputs: std::collections::HashMap<String, f64>,
1406 assets: std::collections::HashMap<String, String>,
1407 ) -> Result<String, AmpError> {
1408 tracing::debug!(
1409 "Creating raw transaction with {} inputs and {} outputs",
1410 inputs.len(),
1411 outputs.len()
1412 );
1413
1414 // Elements RPC createrawtransaction expects:
1415 // createrawtransaction inputs outputs locktime replaceable assets
1416 let params = serde_json::json!([
1417 inputs, // inputs as TxInput array
1418 outputs, // outputs as address->amount map
1419 0, // locktime (0 = no locktime)
1420 false, // replaceable (false = not replaceable)
1421 assets // assets as address->asset_id map
1422 ]);
1423
1424 // Debug: Log the exact parameters being sent to createrawtransaction
1425 tracing::error!("createrawtransaction parameters:");
1426 tracing::error!(
1427 " inputs: {}",
1428 serde_json::to_string_pretty(&inputs).unwrap_or_default()
1429 );
1430 tracing::error!(
1431 " outputs: {}",
1432 serde_json::to_string_pretty(&outputs).unwrap_or_default()
1433 );
1434 tracing::error!(
1435 " assets: {}",
1436 serde_json::to_string_pretty(&assets).unwrap_or_default()
1437 );
1438
1439 let raw_tx: String = self
1440 .rpc_call("createrawtransaction", params)
1441 .await
1442 .map_err(|e| {
1443 tracing::error!("createrawtransaction RPC call failed: {}", e);
1444 e.with_context("Failed to create raw transaction")
1445 })?;
1446
1447 tracing::debug!("Created raw transaction: {}", raw_tx);
1448 Ok(raw_tx)
1449 }
1450
1451 /// Imports an address into a specific wallet as watch-only
1452 ///
1453 /// # Arguments
1454 /// * `wallet_name` - Name of the wallet to import into
1455 /// * `address` - The address to import
1456 /// * `label` - Optional label for the address
1457 /// * `rescan` - Whether to rescan the blockchain for transactions
1458 ///
1459 /// # Errors
1460 /// Returns an error if the RPC call fails
1461 async fn import_address_to_wallet(
1462 &self,
1463 wallet_name: &str,
1464 address: &str,
1465 label: Option<&str>,
1466 rescan: bool,
1467 ) -> Result<(), AmpError> {
1468 tracing::debug!("Importing address {} into wallet {}", address, wallet_name);
1469
1470 // First load the wallet to ensure it's available
1471 self.load_wallet(wallet_name).await?;
1472
1473 let params = serde_json::json!([address, label.unwrap_or(""), rescan]);
1474
1475 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1476
1477 let request = RpcRequest {
1478 jsonrpc: "1.0".to_string(),
1479 id: "amp-client".to_string(),
1480 method: "importaddress".to_string(),
1481 params,
1482 };
1483
1484 let response = self
1485 .client
1486 .post(&wallet_url)
1487 .basic_auth(&self.username, Some(&self.password))
1488 .json(&request)
1489 .send()
1490 .await
1491 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1492
1493 if !response.status().is_success() {
1494 let status = response.status();
1495 let error_body = response
1496 .text()
1497 .await
1498 .unwrap_or_else(|_| "Unable to read error body".to_string());
1499 return Err(AmpError::rpc(format!(
1500 "RPC request failed with status: {status} - Body: {error_body}"
1501 )));
1502 }
1503
1504 let rpc_response: RpcResponse<serde_json::Value> = response
1505 .json()
1506 .await
1507 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1508
1509 if let Some(error) = rpc_response.error {
1510 // Ignore "already imported" errors
1511 if error.code != -4 {
1512 return Err(AmpError::rpc(format!(
1513 "RPC error importing address: {} (code: {})",
1514 error.message, error.code
1515 )));
1516 }
1517 }
1518
1519 tracing::debug!(
1520 "Successfully imported address {} into wallet {}",
1521 address,
1522 wallet_name
1523 );
1524 Ok(())
1525 }
1526
1527 /// Creates a raw transaction using a specific wallet context
1528 ///
1529 /// This method uses the wallet-specific RPC endpoint which is necessary
1530 /// for confidential transactions that require wallet context for blinding keys.
1531 ///
1532 /// # Arguments
1533 /// * `wallet_name` - Name of the wallet to use for transaction creation
1534 /// * `inputs` - Transaction inputs
1535 /// * `outputs` - Map of addresses to amounts
1536 /// * `assets` - Map of addresses to asset IDs
1537 ///
1538 /// # Returns
1539 /// Returns the raw transaction hex
1540 ///
1541 /// # Errors
1542 /// Returns an error if the RPC call fails
1543 #[allow(dead_code)]
1544 #[allow(clippy::cognitive_complexity)]
1545 async fn create_raw_transaction_with_wallet(
1546 &self,
1547 wallet_name: &str,
1548 inputs: Vec<TxInput>,
1549 outputs: std::collections::HashMap<String, f64>,
1550 assets: std::collections::HashMap<String, String>,
1551 ) -> Result<String, AmpError> {
1552 tracing::debug!(
1553 "Creating raw transaction with wallet {} - {} inputs and {} outputs",
1554 wallet_name,
1555 inputs.len(),
1556 outputs.len()
1557 );
1558
1559 // First load the wallet to ensure it's available
1560 self.load_wallet(wallet_name).await?;
1561
1562 // Elements RPC createrawtransaction expects outputs as an array of objects
1563 // Each output object should contain both address, amount, and asset
1564 let mut outputs_array = Vec::new();
1565
1566 for (address, amount) in &outputs {
1567 let asset_id = assets.get(address).ok_or_else(|| {
1568 AmpError::validation(format!("No asset ID found for address {address}"))
1569 })?;
1570
1571 // Convert amount to string with proper precision for Elements
1572 let amount_str = format!("{amount:.8}");
1573
1574 outputs_array.push(serde_json::json!({
1575 address.clone(): amount_str,
1576 "asset": asset_id
1577 }));
1578 }
1579
1580 let params = serde_json::json!([
1581 inputs, // inputs as TxInput array
1582 outputs_array, // outputs as array of {address: amount, asset: id} objects
1583 0, // locktime (0 = no locktime)
1584 false, // replaceable (false = not replaceable)
1585 ]);
1586
1587 // Debug: Log the exact parameters being sent to createrawtransaction
1588 tracing::error!("createrawtransaction parameters (wallet-specific, corrected format):");
1589 tracing::error!(" wallet: {}", wallet_name);
1590 tracing::error!(
1591 " inputs: {}",
1592 serde_json::to_string_pretty(&inputs).unwrap_or_default()
1593 );
1594 tracing::error!(
1595 " outputs_array: {}",
1596 serde_json::to_string_pretty(&outputs_array).unwrap_or_default()
1597 );
1598
1599 // Use the wallet-specific RPC endpoint
1600 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1601
1602 let request = RpcRequest {
1603 jsonrpc: "1.0".to_string(),
1604 id: "amp-client".to_string(),
1605 method: "createrawtransaction".to_string(),
1606 params,
1607 };
1608
1609 let response = self
1610 .client
1611 .post(&wallet_url)
1612 .basic_auth(&self.username, Some(&self.password))
1613 .json(&request)
1614 .send()
1615 .await
1616 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1617
1618 if !response.status().is_success() {
1619 let status = response.status();
1620 let error_body = response
1621 .text()
1622 .await
1623 .unwrap_or_else(|_| "Unable to read error body".to_string());
1624 return Err(AmpError::rpc(format!(
1625 "RPC request failed with status: {status} - Body: {error_body}"
1626 )));
1627 }
1628
1629 let rpc_response: RpcResponse<String> = response
1630 .json()
1631 .await
1632 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1633
1634 if let Some(error) = rpc_response.error {
1635 return Err(AmpError::rpc(format!(
1636 "RPC error creating raw transaction: {} (code: {})",
1637 error.message, error.code
1638 )));
1639 }
1640
1641 let raw_tx = rpc_response
1642 .result
1643 .ok_or_else(|| AmpError::rpc("No raw transaction returned".to_string()))?;
1644
1645 tracing::debug!(
1646 "Created raw transaction with wallet {}: {}",
1647 wallet_name,
1648 raw_tx
1649 );
1650 Ok(raw_tx)
1651 }
1652
1653 /// Imports an address into the wallet as watch-only
1654 ///
1655 /// # Arguments
1656 /// * `address` - The address to import
1657 /// * `label` - Optional label for the address
1658 /// * `rescan` - Whether to rescan the blockchain for transactions
1659 ///
1660 /// # Errors
1661 /// Returns an error if the RPC call fails
1662 ///
1663 /// # Examples
1664 /// ```no_run
1665 /// # use amp_rs::ElementsRpc;
1666 /// # #[tokio::main]
1667 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1668 /// let rpc = ElementsRpc::from_env()?;
1669 /// rpc.import_address("vjU8L4dKa1XyyVcPqKBbTgjT1tRC7qYp5VJGwndZSCFk4ntpWey1pQe6hcSGDMVurr9CsZ21EGsqGjWA", Some("test_address"), false).await?;
1670 /// # Ok(())
1671 /// # }
1672 /// ```
1673 pub async fn import_address(
1674 &self,
1675 address: &str,
1676 label: Option<&str>,
1677 rescan: bool,
1678 ) -> Result<(), AmpError> {
1679 tracing::debug!(
1680 "Importing address: {} with label: {:?}, rescan: {}",
1681 address,
1682 label,
1683 rescan
1684 );
1685
1686 let params = serde_json::json!([address, label.unwrap_or(""), rescan]);
1687
1688 // importaddress returns null on success
1689 let request = RpcRequest {
1690 jsonrpc: "1.0".to_string(),
1691 id: "amp-client".to_string(),
1692 method: "importaddress".to_string(),
1693 params,
1694 };
1695
1696 let response = self
1697 .client
1698 .post(&self.base_url)
1699 .basic_auth(&self.username, Some(&self.password))
1700 .json(&request)
1701 .send()
1702 .await
1703 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1704
1705 if !response.status().is_success() {
1706 return Err(AmpError::rpc(format!(
1707 "RPC request failed with status: {}",
1708 response.status()
1709 )));
1710 }
1711
1712 let rpc_response: RpcResponse<serde_json::Value> = response
1713 .json()
1714 .await
1715 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1716
1717 if let Some(error) = rpc_response.error {
1718 return Err(AmpError::rpc(format!(
1719 "RPC error {}: {}",
1720 error.code, error.message
1721 )));
1722 }
1723
1724 tracing::debug!("Successfully imported address: {}", address);
1725 Ok(())
1726 }
1727
1728 /// Creates or loads a wallet
1729 ///
1730 /// # Arguments
1731 /// * `wallet_name` - Name of the wallet to create or load
1732 /// * `disable_private_keys` - Whether to disable private keys (watch-only wallet)
1733 ///
1734 /// # Errors
1735 /// Returns an error if the RPC call fails
1736 ///
1737 /// # Examples
1738 /// ```no_run
1739 /// # use amp_rs::ElementsRpc;
1740 /// # #[tokio::main]
1741 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1742 /// let rpc = ElementsRpc::from_env()?;
1743 /// rpc.create_wallet("test_wallet", true).await?;
1744 /// # Ok(())
1745 /// # }
1746 /// ```
1747 pub async fn create_wallet(
1748 &self,
1749 wallet_name: &str,
1750 disable_private_keys: bool,
1751 ) -> Result<(), AmpError> {
1752 tracing::debug!(
1753 "Creating wallet: {} with disable_private_keys: {}",
1754 wallet_name,
1755 disable_private_keys
1756 );
1757
1758 let params = serde_json::json!([wallet_name, disable_private_keys]);
1759
1760 let request = RpcRequest {
1761 jsonrpc: "1.0".to_string(),
1762 id: "amp-client".to_string(),
1763 method: "createwallet".to_string(),
1764 params,
1765 };
1766
1767 let response = self
1768 .client
1769 .post(&self.base_url)
1770 .basic_auth(&self.username, Some(&self.password))
1771 .json(&request)
1772 .send()
1773 .await
1774 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1775
1776 if !response.status().is_success() {
1777 return Err(AmpError::rpc(format!(
1778 "RPC request failed with status: {}",
1779 response.status()
1780 )));
1781 }
1782
1783 let rpc_response: RpcResponse<serde_json::Value> = response
1784 .json()
1785 .await
1786 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1787
1788 if let Some(error) = rpc_response.error {
1789 // Ignore "wallet already exists" error
1790 if error.code != -4 {
1791 return Err(AmpError::rpc(format!(
1792 "RPC error {}: {}",
1793 error.code, error.message
1794 )));
1795 }
1796 tracing::debug!("Wallet {} already exists", wallet_name);
1797 } else {
1798 tracing::debug!("Successfully created wallet: {}", wallet_name);
1799 }
1800
1801 Ok(())
1802 }
1803
1804 /// Loads an existing wallet
1805 ///
1806 /// # Arguments
1807 /// * `wallet_name` - Name of the wallet to load
1808 ///
1809 /// # Errors
1810 /// Returns an error if the RPC call fails
1811 ///
1812 /// # Examples
1813 /// ```no_run
1814 /// # use amp_rs::ElementsRpc;
1815 /// # #[tokio::main]
1816 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1817 /// let rpc = ElementsRpc::from_env()?;
1818 /// rpc.load_wallet("test_wallet").await?;
1819 /// # Ok(())
1820 /// # }
1821 /// ```
1822 #[allow(clippy::cognitive_complexity)]
1823 pub async fn load_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
1824 tracing::debug!("Loading wallet: {}", wallet_name);
1825
1826 let params = serde_json::json!([wallet_name]);
1827
1828 let request = RpcRequest {
1829 jsonrpc: "1.0".to_string(),
1830 id: "amp-client".to_string(),
1831 method: "loadwallet".to_string(),
1832 params,
1833 };
1834
1835 let response = self
1836 .client
1837 .post(&self.base_url)
1838 .basic_auth(&self.username, Some(&self.password))
1839 .json(&request)
1840 .send()
1841 .await
1842 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1843
1844 if !response.status().is_success() {
1845 let status = response.status();
1846 let error_body = response
1847 .text()
1848 .await
1849 .unwrap_or_else(|_| "Unable to read error body".to_string());
1850 tracing::debug!(
1851 "Load wallet failed with status: {} - Body: {}",
1852 status,
1853 error_body
1854 );
1855
1856 // For wallet loading, we want to be more permissive with errors
1857 // since the wallet might already be loaded
1858 if status == 500 && error_body.contains("already loaded") {
1859 tracing::debug!(
1860 "Wallet {} appears to already be loaded (500 error)",
1861 wallet_name
1862 );
1863 return Ok(());
1864 }
1865
1866 return Err(AmpError::rpc(format!(
1867 "RPC request failed with status: {status} - Body: {error_body}"
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 // Ignore "wallet already loaded" error
1878 if error.code != -35 {
1879 return Err(AmpError::rpc(format!(
1880 "RPC error {}: {}",
1881 error.code, error.message
1882 )));
1883 }
1884 tracing::debug!("Wallet {} already loaded", wallet_name);
1885 } else {
1886 tracing::debug!("Successfully loaded wallet: {}", wallet_name);
1887 }
1888
1889 Ok(())
1890 }
1891
1892 /// Lists all available wallets
1893 ///
1894 /// # Errors
1895 /// Returns an error if the RPC call fails
1896 ///
1897 /// # Examples
1898 /// ```no_run
1899 /// # use amp_rs::ElementsRpc;
1900 /// # #[tokio::main]
1901 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1902 /// let rpc = ElementsRpc::from_env()?;
1903 /// let wallets = rpc.list_wallets().await?;
1904 /// println!("Available wallets: {:?}", wallets);
1905 /// # Ok(())
1906 /// # }
1907 /// ```
1908 pub async fn list_wallets(&self) -> Result<Vec<String>, AmpError> {
1909 tracing::debug!("Listing available wallets");
1910
1911 let params = serde_json::json!([]);
1912
1913 let wallets: Vec<String> = self
1914 .rpc_call("listwallets", params)
1915 .await
1916 .map_err(|e| e.with_context("Failed to list wallets"))?;
1917
1918 tracing::debug!("Found {} wallets", wallets.len());
1919 Ok(wallets)
1920 }
1921
1922 /// Sets up a watch-only wallet with the given address
1923 ///
1924 /// This is a convenience method that creates a watch-only wallet and imports the address
1925 ///
1926 /// # Arguments
1927 /// * `wallet_name` - Name of the wallet to create
1928 /// * `address` - Address to import as watch-only
1929 /// * `label` - Optional label for the address
1930 ///
1931 /// # Errors
1932 /// Returns an error if wallet creation or address import fails
1933 ///
1934 /// # Examples
1935 /// ```no_run
1936 /// # use amp_rs::ElementsRpc;
1937 /// # #[tokio::main]
1938 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1939 /// let rpc = ElementsRpc::from_env()?;
1940 /// rpc.setup_watch_only_wallet("test_wallet", "vjU8L4dKa1XyyVcPqKBbTgjT1tRC7qYp5VJGwndZSCFk4ntpWey1pQe6hcSGDMVurr9CsZ21EGsqGjWA", Some("treasury")).await?;
1941 /// # Ok(())
1942 /// # }
1943 /// ```
1944 #[allow(clippy::cognitive_complexity)]
1945 pub async fn setup_watch_only_wallet(
1946 &self,
1947 wallet_name: &str,
1948 address: &str,
1949 label: Option<&str>,
1950 ) -> Result<(), AmpError> {
1951 tracing::info!(
1952 "Setting up watch-only wallet '{}' with address: {}",
1953 wallet_name,
1954 address
1955 );
1956
1957 // Try the full wallet setup approach first
1958 match self
1959 .setup_wallet_with_address(wallet_name, address, label)
1960 .await
1961 {
1962 Ok(()) => {
1963 tracing::info!(
1964 "Successfully set up watch-only wallet '{}' with address: {}",
1965 wallet_name,
1966 address
1967 );
1968 return Ok(());
1969 }
1970 Err(e) => {
1971 tracing::warn!(
1972 "Full wallet setup failed: {}, trying direct address import",
1973 e
1974 );
1975 }
1976 }
1977
1978 // Fallback: Try to import the address directly without wallet operations
1979 match self.import_address_direct(address, label).await {
1980 Ok(()) => {
1981 tracing::info!("Successfully imported address directly: {}", address);
1982 Ok(())
1983 }
1984 Err(e) => {
1985 tracing::error!(
1986 "Both wallet setup and direct import failed for address: {}",
1987 address
1988 );
1989 Err(AmpError::rpc(format!(
1990 "Failed to set up watch-only wallet or import address: wallet setup error: {e}, direct import error: {e}"
1991 )))
1992 }
1993 }
1994 }
1995
1996 /// Attempts to set up a wallet with address using the standard approach
1997 async fn setup_wallet_with_address(
1998 &self,
1999 wallet_name: &str,
2000 address: &str,
2001 label: Option<&str>,
2002 ) -> Result<(), AmpError> {
2003 // Try to create the wallet (will ignore if it already exists)
2004 self.create_wallet(wallet_name, true).await?;
2005
2006 // Try to load the wallet (will ignore if already loaded)
2007 self.load_wallet(wallet_name).await?;
2008
2009 // Import the address without rescanning (for faster setup)
2010 self.import_address(address, label, false).await?;
2011
2012 Ok(())
2013 }
2014
2015 /// Attempts to import an address directly without wallet operations
2016 async fn import_address_direct(
2017 &self,
2018 address: &str,
2019 label: Option<&str>,
2020 ) -> Result<(), AmpError> {
2021 tracing::debug!("Attempting direct address import for: {}", address);
2022
2023 // Try to import the address directly (this might work even if wallet operations fail)
2024 self.import_address(address, label, false).await
2025 }
2026
2027 /// Broadcasts a signed raw transaction to the network
2028 ///
2029 /// # Arguments
2030 /// * `hex` - The signed transaction in hexadecimal format
2031 ///
2032 /// # Errors
2033 /// Returns an error if the RPC call fails or transaction broadcast fails
2034 ///
2035 /// # Examples
2036 /// ```no_run
2037 /// # use amp_rs::ElementsRpc;
2038 /// # #[tokio::main]
2039 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2040 /// let rpc = ElementsRpc::from_env()?;
2041 /// let signed_tx_hex = "0200000000..."; // Signed transaction hex
2042 /// let txid = rpc.send_raw_transaction(signed_tx_hex).await?;
2043 /// println!("Transaction broadcast with ID: {}", txid);
2044 /// # Ok(())
2045 /// # }
2046 /// ```
2047 pub async fn send_raw_transaction(&self, hex: &str) -> Result<String, AmpError> {
2048 tracing::debug!(
2049 "Broadcasting raw transaction: {}",
2050 &hex[..std::cmp::min(hex.len(), 64)]
2051 );
2052
2053 let params = serde_json::json!([hex]);
2054
2055 let txid: String = self
2056 .rpc_call("sendrawtransaction", params)
2057 .await
2058 .map_err(|e| {
2059 tracing::error!("Raw transaction broadcast failed: {}", e);
2060 tracing::error!("Transaction hex (first 200 chars): {}", &hex[..std::cmp::min(hex.len(), 200)]);
2061
2062 // Provide specific guidance for blinding-related errors
2063 if e.to_string().contains("bad-txns-in-ne-out") || e.to_string().contains("value in != value out") {
2064 AmpError::rpc(format!(
2065 "Transaction broadcast failed due to confidential transaction blinding error. \
2066 This indicates that the blinding factors don't balance properly. \
2067 Possible solutions:\n\
2068 1. Ensure all addresses have proper blinding keys in the wallet\n\
2069 2. Verify that blindrawtransaction was called before signing\n\
2070 3. Check that UTXO blinding factors match between Elements and LWK\n\
2071 4. Try using unconfidential addresses for testing\n\
2072 Original error: {e}"
2073 ))
2074 } else {
2075 e.with_context("Failed to broadcast raw transaction")
2076 }
2077 })?;
2078
2079 tracing::info!("Successfully broadcast transaction with ID: {}", txid);
2080 Ok(txid)
2081 }
2082
2083 /// Retrieves detailed information about a transaction
2084 ///
2085 /// # Arguments
2086 /// * `txid` - The transaction ID to retrieve
2087 ///
2088 /// # Errors
2089 /// Returns an error if the RPC call fails or transaction is not found
2090 ///
2091 /// # Examples
2092 /// ```no_run
2093 /// # use amp_rs::ElementsRpc;
2094 /// # #[tokio::main]
2095 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2096 /// let rpc = ElementsRpc::from_env()?;
2097 /// let tx_detail = rpc.get_transaction("abc123...").await?;
2098 /// println!("Transaction has {} confirmations", tx_detail.confirmations);
2099 /// # Ok(())
2100 /// # }
2101 /// ```
2102 pub async fn get_transaction(&self, txid: &str) -> Result<TransactionDetail, AmpError> {
2103 tracing::debug!("Retrieving transaction details for: {}", txid);
2104
2105 let params = serde_json::json!([txid, true]); // true for verbose output
2106
2107 let tx_detail: TransactionDetail = self
2108 .rpc_call("gettransaction", params)
2109 .await
2110 .map_err(|e| e.with_context(format!("Failed to get transaction details for {txid}")))?;
2111
2112 tracing::debug!(
2113 "Retrieved transaction {} with {} confirmations",
2114 txid,
2115 tx_detail.confirmations
2116 );
2117
2118 Ok(tx_detail)
2119 }
2120
2121 /// Sends multiple outputs to multiple addresses using Elements' sendmany RPC
2122 ///
2123 /// This method uses Elements' built-in sendmany command which properly handles
2124 /// confidential transactions and blinding. This is the recommended approach for
2125 /// asset distribution as it avoids manual transaction construction issues.
2126 ///
2127 /// # Arguments
2128 /// * `wallet_name` - Name of the Elements wallet to use
2129 /// * `address_amounts` - Map of addresses to amounts to send
2130 /// * `asset_amounts` - Map of addresses to asset IDs for each output
2131 /// * `min_conf` - Minimum confirmations for inputs (default: 1)
2132 /// * `comment` - Optional transaction comment
2133 /// * `subtract_fee_from` - Optional addresses to subtract fees from
2134 /// * `replaceable` - Whether transaction is replaceable (default: false)
2135 /// * `conf_target` - Confirmation target for fee estimation (default: 1)
2136 /// * `estimate_mode` - Fee estimation mode (default: "UNSET")
2137 ///
2138 /// # Returns
2139 /// Returns the transaction ID of the sent transaction
2140 ///
2141 /// # Errors
2142 /// Returns an error if the RPC call fails or transaction creation fails
2143 ///
2144 /// # Examples
2145 /// ```no_run
2146 /// # use amp_rs::ElementsRpc;
2147 /// # use std::collections::HashMap;
2148 /// # #[tokio::main]
2149 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2150 /// let rpc = ElementsRpc::from_env()?;
2151 ///
2152 /// let mut address_amounts = HashMap::new();
2153 /// address_amounts.insert("address1".to_string(), 100.0);
2154 /// address_amounts.insert("address2".to_string(), 50.0);
2155 ///
2156 /// let mut asset_amounts = HashMap::new();
2157 /// asset_amounts.insert("address1".to_string(), "asset_id_hex".to_string());
2158 /// asset_amounts.insert("address2".to_string(), "asset_id_hex".to_string());
2159 ///
2160 /// let txid = rpc.sendmany("wallet_name", address_amounts, asset_amounts, None, None, None, None, None, None).await?;
2161 /// println!("Transaction sent with ID: {}", txid);
2162 /// # Ok(())
2163 /// # }
2164 /// ```
2165 #[allow(clippy::too_many_arguments, clippy::cognitive_complexity)]
2166 pub async fn sendmany(
2167 &self,
2168 wallet_name: &str,
2169 address_amounts: std::collections::HashMap<String, f64>,
2170 asset_amounts: std::collections::HashMap<String, String>,
2171 min_conf: Option<u32>,
2172 comment: Option<&str>,
2173 subtract_fee_from: Option<Vec<String>>,
2174 replaceable: Option<bool>,
2175 conf_target: Option<u32>,
2176 estimate_mode: Option<&str>,
2177 ) -> Result<String, AmpError> {
2178 tracing::debug!(
2179 "Sending to {} addresses using sendmany for wallet {}",
2180 address_amounts.len(),
2181 wallet_name
2182 );
2183
2184 // First load the wallet to ensure it's available
2185 self.load_wallet(wallet_name).await?;
2186
2187 // Elements sendmany parameters:
2188 // 1. dummy (empty string for compatibility)
2189 // 2. amounts (map of address -> amount)
2190 // 3. minconf (minimum confirmations, default 1)
2191 // 4. comment (optional comment)
2192 // 5. subtractfeefrom (array of addresses to subtract fee from)
2193 // 6. replaceable (boolean, default false)
2194 // 7. conf_target (confirmation target for fee estimation)
2195 // 8. estimate_mode (fee estimation mode)
2196 // 9. assetlabel (map of address -> asset_id for multi-asset sends)
2197 let params = serde_json::json!([
2198 "", // dummy (required for compatibility)
2199 address_amounts, // amounts map
2200 min_conf.unwrap_or(1), // minconf
2201 comment.unwrap_or(""), // comment
2202 subtract_fee_from.unwrap_or_default(), // subtractfeefrom
2203 replaceable.unwrap_or(false), // replaceable
2204 conf_target.unwrap_or(1), // conf_target
2205 estimate_mode.unwrap_or("UNSET"), // estimate_mode
2206 asset_amounts // assetlabel (asset map)
2207 ]);
2208
2209 // Use the wallet-specific RPC endpoint
2210 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
2211
2212 let request = RpcRequest {
2213 jsonrpc: "1.0".to_string(),
2214 id: "amp-client".to_string(),
2215 method: "sendmany".to_string(),
2216 params,
2217 };
2218
2219 tracing::debug!("Sendmany request parameters:");
2220 tracing::debug!(" wallet: {}", wallet_name);
2221 tracing::debug!(" address_amounts: {:?}", address_amounts);
2222 tracing::debug!(" asset_amounts: {:?}", asset_amounts);
2223
2224 let response = self
2225 .client
2226 .post(&wallet_url)
2227 .basic_auth(&self.username, Some(&self.password))
2228 .json(&request)
2229 .send()
2230 .await
2231 .map_err(|e| AmpError::rpc(format!("Failed to send sendmany RPC request: {e}")))?;
2232
2233 if !response.status().is_success() {
2234 let status = response.status();
2235 let error_body = response
2236 .text()
2237 .await
2238 .unwrap_or_else(|_| "Unable to read error body".to_string());
2239 return Err(AmpError::rpc(format!(
2240 "Sendmany RPC request failed with status: {status} - Body: {error_body}"
2241 )));
2242 }
2243
2244 let rpc_response: RpcResponse<String> = response
2245 .json()
2246 .await
2247 .map_err(|e| AmpError::rpc(format!("Failed to parse sendmany RPC response: {e}")))?;
2248
2249 if let Some(error) = rpc_response.error {
2250 return Err(AmpError::rpc(format!(
2251 "Sendmany RPC error: {} (code: {})",
2252 error.message, error.code
2253 )));
2254 }
2255
2256 let txid = rpc_response.result.unwrap_or_default();
2257 tracing::info!("Successfully sent transaction with sendmany: {}", txid);
2258 Ok(txid)
2259 }
2260
2261 /// Waits for blockchain confirmations with configurable timeout
2262 ///
2263 /// This method polls the blockchain every 15 seconds to check for transaction confirmations.
2264 /// It waits for a minimum number of confirmations (default 2) before returning successfully.
2265 /// The method includes a configurable timeout to prevent indefinite waiting.
2266 ///
2267 /// # Arguments
2268 /// * `txid` - The transaction ID to monitor for confirmations
2269 /// * `min_confirmations` - Minimum number of confirmations required (default: 2)
2270 /// * `timeout_minutes` - Timeout in minutes (default: 10)
2271 ///
2272 /// # Returns
2273 /// Returns the final `TransactionDetail` when sufficient confirmations are reached
2274 ///
2275 /// # Errors
2276 /// Returns `AmpError::Timeout` if the timeout is exceeded before confirmations are received
2277 /// Returns `AmpError::Rpc` if there are issues communicating with the Elements node
2278 ///
2279 /// # Examples
2280 /// ```no_run
2281 /// # use amp_rs::ElementsRpc;
2282 /// # #[tokio::main]
2283 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2284 /// let rpc = ElementsRpc::from_env()?;
2285 /// let tx_detail = rpc.wait_for_confirmations("abc123...", Some(2), Some(10)).await?;
2286 /// println!("Transaction confirmed with {} confirmations", tx_detail.confirmations);
2287 /// # Ok(())
2288 /// # }
2289 /// ```
2290 pub async fn wait_for_confirmations(
2291 &self,
2292 txid: &str,
2293 min_confirmations: Option<u32>,
2294 timeout_minutes: Option<u64>,
2295 ) -> Result<TransactionDetail, AmpError> {
2296 self.wait_for_confirmations_with_interval(txid, min_confirmations, timeout_minutes, None)
2297 .await
2298 }
2299
2300 /// Internal method for waiting for confirmations with configurable poll interval
2301 /// This is primarily used for testing to avoid long waits
2302 ///
2303 /// # Errors
2304 ///
2305 /// Returns an error if:
2306 /// - The timeout is exceeded before confirmations are received
2307 /// - There are issues communicating with the Elements node
2308 /// - The transaction cannot be found or is invalid
2309 #[allow(clippy::cognitive_complexity)]
2310 pub async fn wait_for_confirmations_with_interval(
2311 &self,
2312 txid: &str,
2313 min_confirmations: Option<u32>,
2314 timeout_minutes: Option<u64>,
2315 poll_interval_secs: Option<u64>,
2316 ) -> Result<TransactionDetail, AmpError> {
2317 let min_confirmations = min_confirmations.unwrap_or(2);
2318 let timeout_minutes = timeout_minutes.unwrap_or(10);
2319 let timeout_duration = if timeout_minutes == 0 {
2320 std::time::Duration::from_secs(3) // Minimum 3 seconds for testing
2321 } else {
2322 std::time::Duration::from_secs(timeout_minutes * 60)
2323 };
2324 let poll_interval = std::time::Duration::from_secs(poll_interval_secs.unwrap_or(15));
2325
2326 tracing::info!(
2327 "Starting confirmation monitoring for transaction {} (min_confirmations: {}, timeout: {} minutes)",
2328 txid,
2329 min_confirmations,
2330 timeout_minutes
2331 );
2332
2333 let start_time = std::time::Instant::now();
2334
2335 loop {
2336 // Check if we've exceeded the timeout
2337 if start_time.elapsed() >= timeout_duration {
2338 let error_msg = format!(
2339 "Timeout waiting for confirmations after {timeout_minutes} minutes. Transaction ID: {txid}. \
2340 You can retry confirmation by calling the confirmation API with this txid."
2341 );
2342 tracing::error!("{}", error_msg);
2343 return Err(AmpError::Timeout(error_msg));
2344 }
2345
2346 // Get current transaction details
2347 match self.get_transaction(txid).await {
2348 Ok(tx_detail) => {
2349 tracing::debug!(
2350 "Transaction {} has {} confirmations (need {})",
2351 txid,
2352 tx_detail.confirmations,
2353 min_confirmations
2354 );
2355
2356 if tx_detail.confirmations >= min_confirmations {
2357 tracing::info!(
2358 "Transaction {} confirmed with {} confirmations",
2359 txid,
2360 tx_detail.confirmations
2361 );
2362 return Ok(tx_detail);
2363 }
2364
2365 // Log progress every few polls to avoid spam
2366 if start_time.elapsed().as_secs() % 60 < 15 {
2367 tracing::info!(
2368 "Waiting for confirmations: {}/{} (elapsed: {}s)",
2369 tx_detail.confirmations,
2370 min_confirmations,
2371 start_time.elapsed().as_secs()
2372 );
2373 }
2374 }
2375 Err(e) => {
2376 tracing::warn!(
2377 "Failed to get transaction details for {}: {}. Retrying in {} seconds...",
2378 txid,
2379 e,
2380 poll_interval.as_secs()
2381 );
2382 // Continue polling even if individual calls fail, as the transaction
2383 // might not be visible immediately after broadcasting
2384 }
2385 }
2386
2387 // Wait before next poll
2388 tokio::time::sleep(poll_interval).await;
2389 }
2390 }
2391
2392 /// Selects appropriate UTXOs to cover the required amount plus fees
2393 ///
2394 /// This method implements a simple UTXO selection algorithm that:
2395 /// 1. Filters UTXOs by asset ID and spendability
2396 /// 2. Sorts UTXOs by amount (largest first) for efficiency
2397 /// 3. Selects UTXOs until the target amount plus estimated fees is covered
2398 ///
2399 /// # Arguments
2400 /// * `asset_id` - The asset ID to select UTXOs for
2401 /// * `target_amount` - The total amount needed for distribution
2402 /// * `estimated_fee` - Estimated transaction fee in the same asset
2403 ///
2404 /// # Returns
2405 /// Returns a tuple of (`selected_utxos`, `total_selected_amount`)
2406 ///
2407 /// # Errors
2408 /// Returns an error if insufficient UTXOs are available or RPC calls fail
2409 ///
2410 /// # Examples
2411 /// ```no_run
2412 /// # use amp_rs::ElementsRpc;
2413 /// # #[tokio::main]
2414 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2415 /// let rpc = ElementsRpc::from_env()?;
2416 /// let (selected_utxos, total_amount) = rpc.select_utxos_for_amount(
2417 /// "wallet_name",
2418 /// "asset_id_hex",
2419 /// 150.0,
2420 /// 0.001
2421 /// ).await?;
2422 /// println!("Selected {} UTXOs totaling {}", selected_utxos.len(), total_amount);
2423 /// # Ok(())
2424 /// # }
2425 /// ```
2426 pub async fn select_utxos_for_amount(
2427 &self,
2428 wallet_name: &str,
2429 asset_id: &str,
2430 target_amount: f64,
2431 estimated_fee: f64,
2432 ) -> Result<(Vec<Unspent>, f64), AmpError> {
2433 tracing::debug!(
2434 "Selecting UTXOs for asset {} from wallet {} - target: {}, fee: {}",
2435 asset_id,
2436 wallet_name,
2437 target_amount,
2438 estimated_fee
2439 );
2440
2441 // Get all UTXOs for this asset from the specified wallet
2442 let mut utxos = self
2443 .list_unspent_for_wallet(wallet_name, Some(asset_id))
2444 .await?;
2445
2446 // Filter for spendable UTXOs only
2447 utxos.retain(|utxo| utxo.spendable && utxo.asset == asset_id);
2448
2449 if utxos.is_empty() {
2450 return Err(AmpError::validation(format!(
2451 "No spendable UTXOs found for asset {asset_id}. \
2452 This typically means:\n\
2453 1. The treasury address is not imported in the Elements node as a watch-only address\n\
2454 2. The asset issuance transaction hasn't been confirmed yet\n\
2455 3. The UTXOs have already been spent\n\
2456 \n\
2457 To fix this:\n\
2458 - Ensure the treasury address is imported: `elements-cli importaddress <treasury_address> treasury false`\n\
2459 - Wait for the asset issuance transaction to be confirmed\n\
2460 - Check that the treasury address matches the one used for asset issuance"
2461 )));
2462 }
2463
2464 // Sort UTXOs by amount (largest first) for efficient selection
2465 utxos.sort_by(|a, b| {
2466 b.amount
2467 .partial_cmp(&a.amount)
2468 .unwrap_or(std::cmp::Ordering::Equal)
2469 });
2470
2471 let required_amount = target_amount + estimated_fee;
2472 let mut selected_utxos = Vec::new();
2473 let mut total_selected = 0.0;
2474
2475 // Select UTXOs until we have enough to cover the required amount
2476 for utxo in utxos {
2477 selected_utxos.push(utxo.clone());
2478 total_selected += utxo.amount;
2479
2480 if total_selected >= required_amount {
2481 break;
2482 }
2483 }
2484
2485 // Check if we have sufficient funds
2486 if total_selected < required_amount {
2487 return Err(AmpError::validation(format!(
2488 "Insufficient UTXOs: need {required_amount}, have {total_selected} (target: {target_amount}, fee: {estimated_fee})"
2489 )));
2490 }
2491
2492 tracing::info!(
2493 "Selected {} UTXOs totaling {} for target {} + fee {}",
2494 selected_utxos.len(),
2495 total_selected,
2496 target_amount,
2497 estimated_fee
2498 );
2499
2500 Ok((selected_utxos, total_selected))
2501 }
2502
2503 /// Builds a raw transaction for asset distribution with proper change handling
2504 ///
2505 /// This method orchestrates the complete transaction building process:
2506 /// 1. Selects appropriate UTXOs using `select_utxos_for_amount`
2507 /// 2. Creates transaction inputs from selected UTXOs
2508 /// 3. Creates outputs for distribution addresses
2509 /// 4. Calculates and creates change output if necessary
2510 /// 5. Builds the raw transaction using `create_raw_transaction`
2511 ///
2512 /// # Arguments
2513 /// * `asset_id` - The asset ID being distributed
2514 /// * `address_amounts` - Map of recipient addresses to amounts
2515 /// * `change_address` - Address to send change to (if any)
2516 /// * `estimated_fee` - Estimated transaction fee
2517 ///
2518 /// # Returns
2519 /// Returns a tuple of (`raw_transaction_hex`, `selected_utxos`, `change_amount`)
2520 ///
2521 /// # Errors
2522 /// Returns an error if UTXO selection fails or transaction building fails
2523 ///
2524 /// # Examples
2525 /// ```no_run
2526 /// # use amp_rs::ElementsRpc;
2527 /// # use std::collections::HashMap;
2528 /// # #[tokio::main]
2529 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2530 /// let rpc = ElementsRpc::from_env()?;
2531 /// let mut address_amounts = HashMap::new();
2532 /// address_amounts.insert("address1".to_string(), 100.0);
2533 /// address_amounts.insert("address2".to_string(), 50.0);
2534 ///
2535 /// let (raw_tx, utxos, change) = rpc.build_distribution_transaction(
2536 /// "wallet_name",
2537 /// "asset_id_hex",
2538 /// address_amounts,
2539 /// "change_address",
2540 /// 0.001
2541 /// ).await?;
2542 /// println!("Built transaction with {} inputs, change: {}", utxos.len(), change);
2543 /// # Ok(())
2544 /// # }
2545 /// ```
2546 #[allow(clippy::cognitive_complexity)]
2547 #[allow(clippy::too_many_lines)]
2548 pub async fn build_distribution_transaction(
2549 &self,
2550 wallet_name: &str,
2551 asset_id: &str,
2552 address_amounts: std::collections::HashMap<String, f64>,
2553 change_address: &str,
2554 _estimated_fee: f64,
2555 ) -> Result<(String, Vec<Unspent>, f64), AmpError> {
2556 const DUST_THRESHOLD: f64 = 0.00001;
2557 const LBTC_ASSET_ID: &str =
2558 "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; // L-BTC on Liquid testnet
2559
2560 tracing::debug!(
2561 "Building distribution transaction for asset {} with {} outputs",
2562 asset_id,
2563 address_amounts.len()
2564 );
2565
2566 // Calculate total distribution amount
2567 let total_distribution: f64 = address_amounts.values().sum();
2568
2569 if total_distribution <= 0.0 {
2570 return Err(AmpError::validation(
2571 "Total distribution amount must be greater than zero".to_string(),
2572 ));
2573 }
2574
2575 // Select UTXOs to cover the distribution (custom asset)
2576 let (selected_asset_utxos, total_selected) = self
2577 .select_utxos_for_amount(wallet_name, asset_id, total_distribution, 0.0)
2578 .await?;
2579
2580 // Also select L-BTC UTXOs for transaction fees
2581 // Elements requires L-BTC inputs for fees even when distributing custom assets
2582 let min_lbtc_fee = 0.00001; // Minimum L-BTC needed for fees
2583 let (selected_lbtc_utxos, lbtc_total) = match self
2584 .select_utxos_for_amount(wallet_name, LBTC_ASSET_ID, 0.0, min_lbtc_fee)
2585 .await
2586 {
2587 Ok((utxos, total)) => {
2588 tracing::info!(
2589 "Selected {} L-BTC UTXOs totaling {} for fees",
2590 utxos.len(),
2591 total
2592 );
2593 (utxos, total)
2594 }
2595 Err(e) => {
2596 tracing::warn!(
2597 "Could not select L-BTC UTXOs for fees: {}. Transaction may fail.",
2598 e
2599 );
2600 (Vec::new(), 0.0)
2601 }
2602 };
2603
2604 // Combine custom asset UTXOs and L-BTC UTXOs
2605 let mut all_utxos = selected_asset_utxos.clone();
2606 all_utxos.extend(selected_lbtc_utxos.clone());
2607
2608 if selected_lbtc_utxos.is_empty() {
2609 tracing::warn!(
2610 "No L-BTC UTXOs selected for fees. Transaction may fail during broadcast."
2611 );
2612 } else {
2613 tracing::info!(
2614 "Transaction includes {} custom asset UTXOs and {} L-BTC UTXOs for fees",
2615 selected_asset_utxos.len(),
2616 selected_lbtc_utxos.len()
2617 );
2618 }
2619
2620 // Create transaction inputs from all selected UTXOs
2621 let inputs: Vec<TxInput> = all_utxos
2622 .iter()
2623 .map(|utxo| TxInput {
2624 txid: utxo.txid.clone(),
2625 vout: utxo.vout,
2626 sequence: None, // Use default sequence
2627 })
2628 .collect();
2629
2630 // Create outputs for distribution (custom asset)
2631 // We need to track outputs as a vector since we may have multiple outputs to the same address
2632 // (e.g., custom asset change + L-BTC change to the same change address)
2633 let mut output_list = Vec::new();
2634
2635 // Add distribution outputs (custom asset)
2636 for (address, amount) in &address_amounts {
2637 output_list.push((address.clone(), *amount, asset_id.to_string()));
2638 }
2639
2640 // Calculate change amount for custom asset (total selected - distribution)
2641 let asset_change_amount = total_selected - total_distribution;
2642
2643 // Add asset change output if there's a significant amount left
2644 if asset_change_amount > DUST_THRESHOLD {
2645 output_list.push((
2646 change_address.to_string(),
2647 asset_change_amount,
2648 asset_id.to_string(),
2649 ));
2650
2651 tracing::debug!(
2652 "Adding asset change output: {} {} to address {}",
2653 asset_change_amount,
2654 asset_id,
2655 change_address
2656 );
2657 } else if asset_change_amount > 0.0 {
2658 tracing::warn!(
2659 "Asset change amount {} is below dust threshold {}, will be lost",
2660 asset_change_amount,
2661 DUST_THRESHOLD
2662 );
2663 }
2664
2665 // Handle L-BTC change if we selected L-BTC UTXOs for fees
2666 // In Elements, the fee is implicit - it's the difference between L-BTC inputs and outputs
2667 // We should NOT subtract the fee from outputs; Elements calculates it automatically
2668 if !selected_lbtc_utxos.is_empty() {
2669 tracing::debug!(
2670 "L-BTC input total: {}, minimum fee needed: {}",
2671 lbtc_total,
2672 min_lbtc_fee
2673 );
2674
2675 // Check if we have enough L-BTC for the minimum fee
2676 if lbtc_total < min_lbtc_fee {
2677 return Err(AmpError::validation(format!(
2678 "Insufficient L-BTC for fees: have {lbtc_total}, need at least {min_lbtc_fee}"
2679 )));
2680 }
2681
2682 // For now, let's try NOT adding any L-BTC change output
2683 // and let Elements handle the fee automatically from the input/output difference
2684 tracing::info!(
2685 "Using L-BTC input {} for fees - no explicit L-BTC change output (Elements will handle fee automatically)",
2686 lbtc_total
2687 );
2688
2689 // Note: If this approach works, the entire L-BTC input will become the fee
2690 // If we need change, we'll need to figure out the correct way to handle it
2691 }
2692
2693 // For confidential addresses, we need to import them into the wallet first
2694 // so Elements knows about the blinding keys
2695 for address in address_amounts.keys() {
2696 if address.starts_with('v') {
2697 // Confidential address
2698 tracing::debug!("Importing confidential address into wallet: {}", address);
2699 if let Err(e) = self
2700 .import_address_to_wallet(wallet_name, address, None, false)
2701 .await
2702 {
2703 tracing::warn!("Failed to import confidential address {}: {}", address, e);
2704 // Continue anyway - the address might already be imported
2705 }
2706 }
2707 }
2708
2709 // Build the raw transaction using wallet-specific endpoint for confidential transactions
2710 // For confidential transactions, we need to use blindrawtransaction to properly handle blinding
2711 let raw_transaction = self
2712 .create_raw_transaction_with_outputs(wallet_name, inputs, output_list)
2713 .await
2714 .map_err(|e| {
2715 // Provide more helpful error message for the common L-BTC fee issue
2716 if e.to_string().contains("bad-txns-in-ne-out") || e.to_string().contains("value in != value out") {
2717 AmpError::validation(format!(
2718 "Transaction failed due to confidential transaction blinding mismatch. \
2719 This occurs when Elements creates blinding factors that don't match LWK's expectations. \
2720 To fix this:\n\
2721 1. Ensure the wallet has proper blinding keys for all addresses\n\
2722 2. Use blindrawtransaction before signing\n\
2723 3. Verify UTXO blinding factors match between Elements and LWK\n\
2724 4. Original error: {e}"
2725 ))
2726 } else {
2727 e.with_context("Failed to build distribution transaction")
2728 }
2729 })?;
2730
2731 // For confidential transactions, we need to blind the transaction properly
2732 // This ensures the blinding factors are compatible with LWK signing
2733 tracing::debug!("Blinding raw transaction for confidential asset distribution");
2734 let blinded_transaction = self
2735 .blind_raw_transaction(wallet_name, &raw_transaction)
2736 .await
2737 .map_err(|e| {
2738 tracing::warn!(
2739 "Failed to blind transaction, proceeding with unblinded: {}",
2740 e
2741 );
2742 // If blinding fails, we'll try to proceed with the unblinded transaction
2743 // This might work for some cases but could fail during broadcast
2744 e.with_context("Transaction blinding failed")
2745 })
2746 .unwrap_or_else(|_| {
2747 tracing::warn!("Using unblinded transaction - this may cause broadcast failures");
2748 raw_transaction.clone()
2749 });
2750
2751 tracing::info!(
2752 "Built distribution transaction: {} inputs, {} outputs, asset change: {}",
2753 all_utxos.len(),
2754 address_amounts.len() + usize::from(asset_change_amount > DUST_THRESHOLD),
2755 if asset_change_amount > DUST_THRESHOLD {
2756 asset_change_amount
2757 } else {
2758 0.0
2759 }
2760 );
2761
2762 Ok((blinded_transaction, all_utxos, asset_change_amount))
2763 }
2764
2765 /// Creates a raw transaction with multiple outputs that can handle multiple assets to the same address
2766 ///
2767 /// This method is similar to `create_raw_transaction_with_wallet` but handles the case where
2768 /// multiple outputs with different assets need to go to the same address (e.g., asset change + L-BTC change).
2769 ///
2770 /// # Arguments
2771 /// * `wallet_name` - Name of the Elements wallet to use
2772 /// * `inputs` - Vector of transaction inputs
2773 /// * `outputs` - Vector of (address, amount, `asset_id`) tuples
2774 ///
2775 /// # Returns
2776 /// Returns the raw transaction hex string
2777 #[allow(clippy::cognitive_complexity)]
2778 async fn create_raw_transaction_with_outputs(
2779 &self,
2780 wallet_name: &str,
2781 inputs: Vec<TxInput>,
2782 outputs: Vec<(String, f64, String)>, // (address, amount, asset_id)
2783 ) -> Result<String, AmpError> {
2784 tracing::debug!(
2785 "Creating raw transaction with wallet {} - {} inputs and {} outputs",
2786 wallet_name,
2787 inputs.len(),
2788 outputs.len()
2789 );
2790
2791 // First load the wallet to ensure it's available
2792 self.load_wallet(wallet_name).await?;
2793
2794 // Elements RPC createrawtransaction expects outputs as an array of objects
2795 // Each output object should contain both address, amount, and asset
2796 let mut outputs_array = Vec::new();
2797
2798 for (address, amount, asset_id) in &outputs {
2799 // Convert amount to string with proper precision for Elements
2800 let amount_str = format!("{amount:.8}");
2801
2802 outputs_array.push(serde_json::json!({
2803 address.clone(): amount_str,
2804 "asset": asset_id
2805 }));
2806 }
2807
2808 let params = serde_json::json!([
2809 inputs, // inputs as TxInput array
2810 outputs_array, // outputs as array of {address: amount, asset: id} objects
2811 0, // locktime (0 = no locktime)
2812 false, // replaceable (false = not replaceable)
2813 ]);
2814
2815 // Debug: Log the exact parameters being sent to createrawtransaction
2816 tracing::error!("createrawtransaction parameters (wallet-specific, corrected format):");
2817 tracing::error!(" wallet: {}", wallet_name);
2818 tracing::error!(
2819 " inputs: {}",
2820 serde_json::to_string_pretty(&inputs).unwrap_or_default()
2821 );
2822 tracing::error!(
2823 " outputs_array: {}",
2824 serde_json::to_string_pretty(&outputs_array).unwrap_or_default()
2825 );
2826
2827 // Use the wallet-specific RPC endpoint
2828 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
2829
2830 let request = RpcRequest {
2831 jsonrpc: "1.0".to_string(),
2832 id: "amp-client".to_string(),
2833 method: "createrawtransaction".to_string(),
2834 params,
2835 };
2836
2837 let response = self
2838 .client
2839 .post(&wallet_url)
2840 .basic_auth(&self.username, Some(&self.password))
2841 .json(&request)
2842 .send()
2843 .await
2844 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
2845
2846 if !response.status().is_success() {
2847 let status = response.status();
2848 let error_body = response
2849 .text()
2850 .await
2851 .unwrap_or_else(|_| "Unable to read error body".to_string());
2852 return Err(AmpError::rpc(format!(
2853 "RPC request failed with status: {status} - Body: {error_body}"
2854 )));
2855 }
2856
2857 let rpc_response: RpcResponse<String> = response
2858 .json()
2859 .await
2860 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
2861
2862 if let Some(error) = rpc_response.error {
2863 return Err(AmpError::rpc(format!(
2864 "RPC error creating raw transaction: {} (code: {})",
2865 error.message, error.code
2866 )));
2867 }
2868
2869 Ok(rpc_response.result.unwrap_or_default())
2870 }
2871
2872 /// Blinds a raw transaction for confidential transactions
2873 ///
2874 /// This method uses Elements' blindrawtransaction RPC to properly blind a transaction
2875 /// for confidential asset transfers. This is crucial for Liquid transactions to ensure
2876 /// the blinding factors are properly balanced.
2877 ///
2878 /// # Arguments
2879 /// * `wallet_name` - Name of the Elements wallet to use for blinding
2880 /// * `raw_transaction` - The raw transaction hex to blind
2881 ///
2882 /// # Returns
2883 /// Returns the blinded transaction hex string
2884 ///
2885 /// # Errors
2886 /// Returns an error if the RPC call fails or blinding is not possible
2887 pub async fn blind_raw_transaction(
2888 &self,
2889 wallet_name: &str,
2890 raw_transaction: &str,
2891 ) -> Result<String, AmpError> {
2892 tracing::debug!(
2893 "Blinding raw transaction for wallet {} - tx length: {} chars",
2894 wallet_name,
2895 raw_transaction.len()
2896 );
2897
2898 // First load the wallet to ensure it's available
2899 self.load_wallet(wallet_name).await?;
2900
2901 // Elements blindrawtransaction parameters:
2902 // 1. Raw transaction hex
2903 // 2. Input blinding data (can be empty array for auto-detection)
2904 // 3. Input amounts (can be empty array for auto-detection from UTXOs)
2905 // 4. Input assets (can be empty array for auto-detection from UTXOs)
2906 // 5. Input asset blinders (can be empty array for auto-detection)
2907 // 6. Input amount blinders (can be empty array for auto-detection)
2908 let params = serde_json::json!([
2909 raw_transaction, // Raw transaction hex
2910 [], // Input blinding data (empty for auto-detection)
2911 [], // Input amounts (empty for auto-detection)
2912 [], // Input assets (empty for auto-detection)
2913 [], // Input asset blinders (empty for auto-detection)
2914 [] // Input amount blinders (empty for auto-detection)
2915 ]);
2916
2917 // Use the wallet-specific RPC endpoint
2918 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
2919
2920 let request = RpcRequest {
2921 jsonrpc: "1.0".to_string(),
2922 id: "amp-client".to_string(),
2923 method: "blindrawtransaction".to_string(),
2924 params,
2925 };
2926
2927 let response = self
2928 .client
2929 .post(&wallet_url)
2930 .basic_auth(&self.username, Some(&self.password))
2931 .json(&request)
2932 .send()
2933 .await
2934 .map_err(|e| {
2935 AmpError::rpc(format!("Failed to send blindrawtransaction request: {e}"))
2936 })?;
2937
2938 if !response.status().is_success() {
2939 let status = response.status();
2940 let error_body = response
2941 .text()
2942 .await
2943 .unwrap_or_else(|_| "Unable to read error body".to_string());
2944 return Err(AmpError::rpc(format!(
2945 "blindrawtransaction failed with status: {status} - Body: {error_body}"
2946 )));
2947 }
2948
2949 let rpc_response: RpcResponse<String> = response.json().await.map_err(|e| {
2950 AmpError::rpc(format!("Failed to parse blindrawtransaction response: {e}"))
2951 })?;
2952
2953 if let Some(error) = rpc_response.error {
2954 return Err(AmpError::rpc(format!(
2955 "RPC error blinding transaction: {} (code: {})",
2956 error.message, error.code
2957 )));
2958 }
2959
2960 let blinded_tx = rpc_response.result.unwrap_or_default();
2961
2962 tracing::info!(
2963 "Successfully blinded transaction - original: {} chars, blinded: {} chars",
2964 raw_transaction.len(),
2965 blinded_tx.len()
2966 );
2967
2968 Ok(blinded_tx)
2969 }
2970
2971 /// Signs a raw transaction using the provided signer callback
2972 ///
2973 /// This method integrates with the Signer trait to sign unsigned transactions.
2974 /// It handles the complete signing workflow including:
2975 /// 1. Validation of the unsigned transaction hex format
2976 /// 2. Calling the signer's `sign_transaction` method
2977 /// 3. Validation of the signed transaction format and structure
2978 /// 4. Proper error handling and context propagation
2979 ///
2980 /// # Arguments
2981 /// * `unsigned_tx_hex` - The unsigned transaction in hexadecimal format
2982 /// * `signer` - Implementation of the Signer trait for transaction signing
2983 ///
2984 /// # Returns
2985 /// Returns the signed transaction as a hex string
2986 ///
2987 /// # Errors
2988 /// Returns an error if:
2989 /// - The unsigned transaction hex is invalid or malformed
2990 /// - The signer fails to sign the transaction
2991 /// - The signed transaction format is invalid
2992 /// - Any validation checks fail
2993 ///
2994 /// # Examples
2995 /// ```no_run
2996 /// # use amp_rs::{ElementsRpc, signer::{Signer, LwkSoftwareSigner}};
2997 /// # #[tokio::main]
2998 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2999 /// let rpc = ElementsRpc::from_env()?;
3000 /// let (_, signer) = LwkSoftwareSigner::generate_new()?;
3001 /// let unsigned_tx = "020000000001..."; // Unsigned transaction hex
3002 /// let signed_tx = rpc.sign_transaction(unsigned_tx, &signer).await?;
3003 /// println!("Transaction signed successfully: {}", signed_tx);
3004 /// # Ok(())
3005 /// # }
3006 /// ```
3007 #[allow(clippy::cognitive_complexity)]
3008 pub async fn sign_transaction(
3009 &self,
3010 unsigned_tx_hex: &str,
3011 signer: &dyn crate::signer::Signer,
3012 ) -> Result<String, AmpError> {
3013 const MIN_TX_SIZE: usize = 10; // Minimum bytes for a valid transaction
3014
3015 tracing::debug!(
3016 "Signing transaction: {}...",
3017 &unsigned_tx_hex[..std::cmp::min(unsigned_tx_hex.len(), 64)]
3018 );
3019
3020 // Validate unsigned transaction hex format
3021 if unsigned_tx_hex.is_empty() {
3022 return Err(AmpError::validation(
3023 "Unsigned transaction hex cannot be empty".to_string(),
3024 ));
3025 }
3026
3027 // Check if hex string has valid format (even length, valid hex characters)
3028 if unsigned_tx_hex.len() % 2 != 0 {
3029 return Err(AmpError::validation(
3030 "Unsigned transaction hex must have even length".to_string(),
3031 ));
3032 }
3033
3034 // Validate hex characters
3035 if !unsigned_tx_hex.chars().all(|c| c.is_ascii_hexdigit()) {
3036 return Err(AmpError::validation(
3037 "Unsigned transaction contains invalid hex characters".to_string(),
3038 ));
3039 }
3040
3041 // Attempt to decode hex to validate transaction structure
3042 let tx_bytes = hex::decode(unsigned_tx_hex).map_err(|e| {
3043 AmpError::validation(format!("Failed to decode unsigned transaction hex: {e}"))
3044 })?;
3045
3046 tracing::debug!("Unsigned transaction validation passed, calling signer");
3047
3048 // Call the signer to sign the transaction
3049 let signed_tx_hex = signer
3050 .sign_transaction(unsigned_tx_hex)
3051 .await
3052 .map_err(|e| {
3053 tracing::error!("Transaction signing failed: {}", e);
3054 AmpError::Signer(e).with_context("Failed to sign transaction")
3055 })?;
3056
3057 tracing::debug!(
3058 "Signer returned signed transaction: {}...",
3059 &signed_tx_hex[..std::cmp::min(signed_tx_hex.len(), 64)]
3060 );
3061
3062 // Validate signed transaction format and structure
3063 if signed_tx_hex.is_empty() {
3064 return Err(AmpError::validation(
3065 "Signed transaction hex cannot be empty".to_string(),
3066 ));
3067 }
3068
3069 // Check if signed transaction has valid hex format
3070 if signed_tx_hex.len() % 2 != 0 {
3071 return Err(AmpError::validation(
3072 "Signed transaction hex must have even length".to_string(),
3073 ));
3074 }
3075
3076 // Validate hex characters in signed transaction
3077 if !signed_tx_hex.chars().all(|c| c.is_ascii_hexdigit()) {
3078 return Err(AmpError::validation(
3079 "Signed transaction contains invalid hex characters".to_string(),
3080 ));
3081 }
3082
3083 // Attempt to decode signed transaction to validate structure
3084 let signed_tx_bytes = hex::decode(&signed_tx_hex).map_err(|e| {
3085 AmpError::validation(format!("Failed to decode signed transaction hex: {e}"))
3086 })?;
3087
3088 // Basic validation: signed transaction should be at least as long as unsigned
3089 // (signatures add data, so signed tx should be larger or equal)
3090 if signed_tx_bytes.len() < tx_bytes.len() {
3091 return Err(AmpError::validation(
3092 "Signed transaction is shorter than unsigned transaction, which is invalid"
3093 .to_string(),
3094 ));
3095 }
3096
3097 // Additional validation: check that the transaction structure is reasonable
3098 // Minimum transaction size for Elements (very basic check)
3099 if signed_tx_bytes.len() < MIN_TX_SIZE {
3100 return Err(AmpError::validation(format!(
3101 "Signed transaction does not meet minimum size ({} bytes), minimum is {} bytes",
3102 signed_tx_bytes.len(),
3103 MIN_TX_SIZE
3104 )));
3105 }
3106
3107 tracing::info!(
3108 "Transaction signed successfully - unsigned: {} bytes, signed: {} bytes",
3109 tx_bytes.len(),
3110 signed_tx_bytes.len()
3111 );
3112
3113 Ok(signed_tx_hex)
3114 }
3115
3116 /// Signs and broadcasts a transaction in a single operation
3117 ///
3118 /// This is a convenience method that combines transaction signing and broadcasting.
3119 /// It performs the complete workflow of signing an unsigned transaction and
3120 /// immediately broadcasting it to the network.
3121 ///
3122 /// # Arguments
3123 /// * `unsigned_tx_hex` - The unsigned transaction in hexadecimal format
3124 /// * `signer` - Implementation of the Signer trait for transaction signing
3125 ///
3126 /// # Returns
3127 /// Returns the transaction ID of the broadcast transaction
3128 ///
3129 /// # Errors
3130 /// Returns an error if signing or broadcasting fails
3131 ///
3132 /// # Examples
3133 /// ```no_run
3134 /// # use amp_rs::{ElementsRpc, signer::{Signer, LwkSoftwareSigner}};
3135 /// # #[tokio::main]
3136 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3137 /// let rpc = ElementsRpc::from_env()?;
3138 /// let (_, signer) = LwkSoftwareSigner::generate_new()?;
3139 /// let unsigned_tx = "020000000001..."; // Unsigned transaction hex
3140 /// let txid = rpc.sign_and_broadcast_transaction(unsigned_tx, &signer).await?;
3141 /// println!("Transaction broadcast with ID: {}", txid);
3142 /// # Ok(())
3143 /// # }
3144 /// ```
3145 pub async fn sign_and_broadcast_transaction(
3146 &self,
3147 unsigned_tx_hex: &str,
3148 signer: &dyn crate::signer::Signer,
3149 ) -> Result<String, AmpError> {
3150 tracing::info!("Signing and broadcasting transaction");
3151
3152 // Sign the transaction
3153 let signed_tx_hex = self
3154 .sign_transaction(unsigned_tx_hex, signer)
3155 .await
3156 .map_err(|e| e.with_context("Failed during transaction signing phase"))?;
3157
3158 // Broadcast the signed transaction
3159 let txid = self
3160 .send_raw_transaction(&signed_tx_hex)
3161 .await
3162 .map_err(|e| e.with_context("Failed during transaction broadcast phase"))?;
3163
3164 tracing::info!("Successfully signed and broadcast transaction: {}", txid);
3165 Ok(txid)
3166 }
3167
3168 /// Signs and broadcasts a transaction with UTXO information for proper PSBT construction
3169 ///
3170 /// This method provides UTXO information to the signer for proper PSBT construction,
3171 /// which is required for confidential transactions where the signer needs to know
3172 /// the previous transaction outputs being spent.
3173 ///
3174 /// # Arguments
3175 /// * `unsigned_tx_hex` - The unsigned transaction in hexadecimal format
3176 /// * `utxos` - Vector of UTXOs being spent in the transaction
3177 /// * `signer` - Implementation of the Signer trait for transaction signing
3178 ///
3179 /// # Returns
3180 /// Returns the transaction ID of the broadcast transaction
3181 ///
3182 /// # Errors
3183 /// Returns an error if signing or broadcasting fails
3184 #[allow(clippy::cognitive_complexity)]
3185 pub async fn sign_and_broadcast_transaction_with_utxos(
3186 &self,
3187 unsigned_tx_hex: &str,
3188 utxos: &[Unspent],
3189 signer: &dyn crate::signer::Signer,
3190 ) -> Result<String, AmpError> {
3191 tracing::info!(
3192 "Signing and broadcasting transaction with {} UTXOs",
3193 utxos.len()
3194 );
3195
3196 // Try to use the enhanced signing method if the signer supports it
3197 let signed_tx_hex = if let Some(lwk_signer) = signer
3198 .as_any()
3199 .downcast_ref::<crate::signer::LwkSoftwareSigner>(
3200 ) {
3201 // Use the enhanced signing method with UTXO information
3202 tracing::debug!("Using LWK signer with UTXO information");
3203 lwk_signer
3204 .sign_transaction_with_utxos(unsigned_tx_hex, utxos)
3205 .await
3206 .map_err(|e| {
3207 AmpError::Signer(e)
3208 .with_context("Failed during enhanced transaction signing phase")
3209 })?
3210 } else {
3211 // Fall back to standard signing method
3212 tracing::debug!("Using standard signing method (no UTXO information)");
3213 self.sign_transaction(unsigned_tx_hex, signer)
3214 .await
3215 .map_err(|e| e.with_context("Failed during transaction signing phase"))?
3216 };
3217
3218 // Broadcast the signed transaction
3219 let txid = self
3220 .send_raw_transaction(&signed_tx_hex)
3221 .await
3222 .map_err(|e| e.with_context("Failed during transaction broadcast phase"))?;
3223
3224 tracing::info!("Successfully signed and broadcast transaction: {}", txid);
3225 Ok(txid)
3226 }
3227
3228 /// Collects change data from a confirmed transaction for distribution confirmation
3229 ///
3230 /// This method queries the Elements node to find change UTXOs from a specific transaction
3231 /// that belong to the specified asset. It's used after a distribution transaction is
3232 /// confirmed to collect the change outputs for the final confirmation API call.
3233 ///
3234 /// # Arguments
3235 /// * `asset_id` - The asset ID to filter change UTXOs for
3236 /// * `txid` - The transaction ID to filter change UTXOs from
3237 ///
3238 /// # Returns
3239 /// Returns a vector of Unspent UTXOs that represent change outputs from the transaction.
3240 /// Returns an empty vector if no change outputs exist for the specified asset and transaction.
3241 ///
3242 /// # Errors
3243 /// Returns an error if the RPC call fails or if there are issues querying the Elements node
3244 ///
3245 /// # Examples
3246 /// ```no_run
3247 /// # use amp_rs::ElementsRpc;
3248 /// # #[tokio::main]
3249 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3250 /// let rpc = ElementsRpc::from_env()?;
3251 /// let change_data = rpc.collect_change_data(
3252 /// "asset_id_hex",
3253 /// "transaction_id_hex",
3254 /// &rpc,
3255 /// "wallet_name"
3256 /// ).await?;
3257 ///
3258 /// if change_data.is_empty() {
3259 /// println!("No change outputs found for this transaction");
3260 /// } else {
3261 /// println!("Found {} change outputs", change_data.len());
3262 /// }
3263 /// # Ok(())
3264 /// # }
3265 /// ```
3266 #[allow(clippy::cognitive_complexity)]
3267 pub async fn collect_change_data(
3268 &self,
3269 asset_id: &str,
3270 txid: &str,
3271 node_rpc: &Self,
3272 wallet_name: &str,
3273 ) -> Result<Vec<Unspent>, AmpError> {
3274 tracing::debug!(
3275 "Collecting change data for asset {} from transaction {}",
3276 asset_id,
3277 txid
3278 );
3279
3280 // Use the raw listunspent RPC call to get full blinding information
3281 // This is essential for confidential transactions as the AMP API requires
3282 // both amountblinder and assetblinder fields
3283 let all_utxos = node_rpc
3284 .list_unspent_with_blinding_data(wallet_name)
3285 .await
3286 .map_err(|e| {
3287 e.with_context(
3288 "Failed to query unspent outputs with blinding data for change data collection",
3289 )
3290 })?;
3291
3292 // Filter UTXOs to only include those from the specified transaction
3293 let change_utxos: Vec<Unspent> = all_utxos
3294 .into_iter()
3295 .filter(|utxo| {
3296 // Match UTXOs that:
3297 // 1. Come from the specified transaction (txid matches)
3298 // 2. Are for the correct asset
3299 // 3. Are spendable
3300 utxo.txid == txid && utxo.asset == asset_id && utxo.spendable
3301 })
3302 .collect();
3303
3304 tracing::info!(
3305 "Collected {} change UTXOs for asset {} from transaction {}",
3306 change_utxos.len(),
3307 asset_id,
3308 txid
3309 );
3310
3311 // Log details of found change UTXOs for debugging
3312 for (index, utxo) in change_utxos.iter().enumerate() {
3313 tracing::debug!(
3314 "Change UTXO {}: txid={}, vout={}, amount={}, asset={}, amountblinder={:?}, assetblinder={:?}",
3315 index + 1,
3316 utxo.txid,
3317 utxo.vout,
3318 utxo.amount,
3319 utxo.asset,
3320 utxo.amountblinder,
3321 utxo.assetblinder
3322 );
3323 }
3324
3325 // Handle the case where no change outputs exist
3326 if change_utxos.is_empty() {
3327 tracing::info!(
3328 "No change outputs found for asset {} in transaction {} - this is normal if all funds were distributed",
3329 asset_id,
3330 txid
3331 );
3332 }
3333
3334 Ok(change_utxos)
3335 }
3336
3337 /// Lists unspent outputs with full blinding data for confidential transactions
3338 ///
3339 /// This method calls the raw `listunspent` RPC to get complete UTXO information
3340 /// including blinding data (amountblinder and assetblinder) which is required
3341 /// for confidential transaction confirmation with the AMP API.
3342 ///
3343 /// # Arguments
3344 /// * `wallet_name` - Name of the Elements wallet to query
3345 ///
3346 /// # Returns
3347 /// Returns a vector of `Unspent` structs with complete blinding information
3348 ///
3349 /// # Errors
3350 /// Returns an error if the RPC call fails
3351 ///
3352 /// # Examples
3353 /// ```no_run
3354 /// # use amp_rs::ElementsRpc;
3355 /// # #[tokio::main]
3356 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3357 /// let rpc = ElementsRpc::from_env()?;
3358 /// let utxos = rpc.list_unspent_with_blinding_data("wallet_name").await?;
3359 /// for utxo in utxos {
3360 /// println!("UTXO: {} with blinders: {:?}, {:?}",
3361 /// utxo.txid, utxo.amountblinder, utxo.assetblinder);
3362 /// }
3363 /// # Ok(())
3364 /// # }
3365 /// ```
3366 pub async fn list_unspent_with_blinding_data(
3367 &self,
3368 wallet_name: &str,
3369 ) -> Result<Vec<Unspent>, AmpError> {
3370 tracing::debug!(
3371 "Listing unspent outputs with blinding data for wallet: {}",
3372 wallet_name
3373 );
3374
3375 // First load the wallet to ensure it's available
3376 self.load_wallet(wallet_name).await?;
3377
3378 // Call listunspent with parameters to get all UTXOs
3379 // Parameters: minconf, maxconf, addresses, include_unsafe, query_options
3380 let params = serde_json::json!([
3381 0, // minconf: include unconfirmed
3382 9_999_999, // maxconf: include all confirmed
3383 [], // addresses: empty array means all addresses
3384 true, // include_unsafe: include unconfirmed transactions
3385 {} // query_options: empty object for default options
3386 ]);
3387
3388 // Use the wallet-specific RPC endpoint
3389 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3390
3391 let request = RpcRequest {
3392 jsonrpc: "1.0".to_string(),
3393 id: "amp-client".to_string(),
3394 method: "listunspent".to_string(),
3395 params,
3396 };
3397
3398 let response = self
3399 .client
3400 .post(&wallet_url)
3401 .basic_auth(&self.username, Some(&self.password))
3402 .json(&request)
3403 .send()
3404 .await
3405 .map_err(|e| AmpError::rpc(format!("Failed to send listunspent RPC request: {e}")))?;
3406
3407 if !response.status().is_success() {
3408 let status = response.status();
3409 let error_body = response
3410 .text()
3411 .await
3412 .unwrap_or_else(|_| "Unable to read error body".to_string());
3413 return Err(AmpError::rpc(format!(
3414 "Listunspent RPC request failed with status: {status} - Body: {error_body}"
3415 )));
3416 }
3417
3418 let rpc_response: RpcResponse<Vec<Unspent>> = response
3419 .json()
3420 .await
3421 .map_err(|e| AmpError::rpc(format!("Failed to parse listunspent RPC response: {e}")))?;
3422
3423 if let Some(error) = rpc_response.error {
3424 return Err(AmpError::rpc(format!(
3425 "Listunspent RPC error: {} (code: {})",
3426 error.message, error.code
3427 )));
3428 }
3429
3430 let utxos = rpc_response.result.unwrap_or_default();
3431 tracing::info!(
3432 "Retrieved {} UTXOs with blinding data from wallet {}",
3433 utxos.len(),
3434 wallet_name
3435 );
3436
3437 Ok(utxos)
3438 }
3439
3440 /// Creates a standard wallet in Elements (Elements-first approach)
3441 ///
3442 /// This method creates a new standard wallet in the Elements node that can generate
3443 /// addresses and private keys. This is part of the Elements-first approach where
3444 /// we create the wallet in Elements first, then export keys to LWK.
3445 ///
3446 /// # Arguments
3447 /// * `wallet_name` - Name for the new wallet
3448 ///
3449 /// # Errors
3450 /// Returns an error if the RPC call fails
3451 ///
3452 /// # Examples
3453 /// ```no_run
3454 /// # use amp_rs::ElementsRpc;
3455 /// # #[tokio::main]
3456 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3457 /// let rpc = ElementsRpc::from_env()?;
3458 /// rpc.create_elements_wallet("test_wallet").await?;
3459 /// # Ok(())
3460 /// # }
3461 /// ```
3462 pub async fn create_elements_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
3463 let params = serde_json::json!([wallet_name]);
3464
3465 let _result: serde_json::Value = self.rpc_call("createwallet", params).await?;
3466
3467 tracing::info!("Successfully created Elements wallet: {}", wallet_name);
3468 Ok(())
3469 }
3470
3471 /// Get a new address from an Elements wallet
3472 ///
3473 /// This method requests a new address from the specified Elements wallet.
3474 /// The address will be generated by Elements and can be used for receiving funds.
3475 /// Defaults to native segwit (bech32) addresses for optimal compatibility.
3476 ///
3477 /// # Arguments
3478 /// * `wallet_name` - Name of the wallet to get address from
3479 /// * `address_type` - Optional address type ("bech32", "legacy", "p2sh-segwit"). Defaults to "bech32"
3480 ///
3481 /// # Errors
3482 /// Returns an error if the RPC call fails or the response format is unexpected
3483 ///
3484 /// # Examples
3485 /// ```no_run
3486 /// # use amp_rs::ElementsRpc;
3487 /// # #[tokio::main]
3488 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3489 /// let rpc = ElementsRpc::from_env()?;
3490 ///
3491 /// // Generate native segwit address (default)
3492 /// let address = rpc.get_new_address("test_wallet", None).await?;
3493 ///
3494 /// // Or explicitly request native segwit
3495 /// let bech32_address = rpc.get_new_address("test_wallet", Some("bech32")).await?;
3496 ///
3497 /// println!("Native segwit address: {}", address);
3498 /// # Ok(())
3499 /// # }
3500 /// ```
3501 pub async fn get_new_address(
3502 &self,
3503 wallet_name: &str,
3504 address_type: Option<&str>,
3505 ) -> Result<String, AmpError> {
3506 // First load the wallet to ensure it's available
3507 self.load_wallet(wallet_name).await?;
3508
3509 // Set default to native segwit (bech32) for Elements
3510 let addr_type = address_type.unwrap_or("bech32");
3511
3512 // For Elements, we need to use the correct parameters for getnewaddress
3513 // getnewaddress [label] [address_type]
3514 let params = serde_json::json!(["", addr_type]);
3515
3516 // Create RPC request for getnewaddress
3517 let request = RpcRequest {
3518 jsonrpc: "1.0".to_string(),
3519 id: "amp-client".to_string(),
3520 method: "getnewaddress".to_string(),
3521 params,
3522 };
3523
3524 // Use the wallet-specific RPC endpoint
3525 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3526
3527 let response = self
3528 .client
3529 .post(&wallet_url)
3530 .basic_auth(&self.username, Some(&self.password))
3531 .json(&request)
3532 .send()
3533 .await
3534 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3535
3536 if !response.status().is_success() {
3537 let status = response.status();
3538 let error_body = response
3539 .text()
3540 .await
3541 .unwrap_or_else(|_| "Unable to read error body".to_string());
3542 return Err(AmpError::rpc(format!(
3543 "RPC request failed with status: {status} - Body: {error_body}"
3544 )));
3545 }
3546
3547 let rpc_response: RpcResponse<serde_json::Value> = response
3548 .json()
3549 .await
3550 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
3551
3552 if let Some(error) = rpc_response.error {
3553 return Err(AmpError::rpc(format!(
3554 "RPC error getting new address: {} (code: {})",
3555 error.message, error.code
3556 )));
3557 }
3558
3559 if let Some(result) = rpc_response.result {
3560 if let Some(address) = result.as_str() {
3561 tracing::info!("Generated new {} address: {}", addr_type, address);
3562 return Ok(address.to_string());
3563 }
3564 }
3565
3566 Err(AmpError::rpc(format!(
3567 "Failed to get new address from wallet '{wallet_name}': unexpected response format"
3568 )))
3569 }
3570
3571 /// Get the confidential version of an address from Elements wallet
3572 ///
3573 /// This method takes a regular (unconfidential) address and returns its confidential
3574 /// counterpart, which includes blinding keys for confidential transactions.
3575 ///
3576 /// # Arguments
3577 ///
3578 /// * `wallet_name` - Name of the Elements wallet
3579 /// * `address` - The unconfidential address to get info for
3580 ///
3581 /// # Returns
3582 ///
3583 /// Returns the confidential address string
3584 ///
3585 /// # Example
3586 ///
3587 /// ```no_run
3588 /// # use amp_rs::ElementsRpc;
3589 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
3590 /// let rpc = ElementsRpc::from_env()?;
3591 /// let unconfidential_address = "tex1q...";
3592 /// // Note: This would need to be called in an async context
3593 /// // let confidential_address = rpc.get_confidential_address("test_wallet", unconfidential_address).await?;
3594 /// // println!("Confidential address: {}", confidential_address);
3595 /// # Ok(())
3596 /// # }
3597 /// ```
3598 /// Gets the confidential address for a given unconfidential address from a wallet
3599 ///
3600 /// # Errors
3601 /// Returns an error if the RPC call fails or the response format is unexpected
3602 pub async fn get_confidential_address(
3603 &self,
3604 wallet_name: &str,
3605 address: &str,
3606 ) -> Result<String, AmpError> {
3607 // First load the wallet to ensure it's available
3608 self.load_wallet(wallet_name).await?;
3609
3610 let params = serde_json::json!([address]);
3611
3612 // Create RPC request for getaddressinfo
3613 let request = RpcRequest {
3614 jsonrpc: "1.0".to_string(),
3615 id: "amp-client".to_string(),
3616 method: "getaddressinfo".to_string(),
3617 params,
3618 };
3619
3620 // Use the wallet-specific RPC endpoint
3621 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3622
3623 let response = self
3624 .client
3625 .post(&wallet_url)
3626 .basic_auth(&self.username, Some(&self.password))
3627 .json(&request)
3628 .send()
3629 .await
3630 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3631
3632 if !response.status().is_success() {
3633 let status = response.status();
3634 let error_body = response
3635 .text()
3636 .await
3637 .unwrap_or_else(|_| "Unable to read error body".to_string());
3638 return Err(AmpError::rpc(format!(
3639 "RPC request failed with status: {status} - Body: {error_body}"
3640 )));
3641 }
3642
3643 let rpc_response: RpcResponse<serde_json::Value> = response
3644 .json()
3645 .await
3646 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
3647
3648 if let Some(error) = rpc_response.error {
3649 return Err(AmpError::rpc(format!(
3650 "RPC error getting address info: {} (code: {})",
3651 error.message, error.code
3652 )));
3653 }
3654
3655 if let Some(result) = rpc_response.result {
3656 if let Some(confidential_address) = result.get("confidential").and_then(|v| v.as_str())
3657 {
3658 tracing::info!("Retrieved confidential address for: {}", address);
3659 return Ok(confidential_address.to_string());
3660 }
3661 }
3662
3663 Err(AmpError::rpc(format!(
3664 "Failed to get confidential address for '{address}': unexpected response format"
3665 )))
3666 }
3667
3668 /// Get the private key for an address from Elements wallet
3669 ///
3670 /// This method exports the private key for a specific address from the Elements wallet.
3671 /// The private key can then be imported into LWK for signing.
3672 ///
3673 /// Note: This is a simplified implementation that returns a placeholder private key.
3674 /// For production use, implement proper wallet-specific RPC calls.
3675 ///
3676 /// # Arguments
3677 /// * `wallet_name` - Name of the wallet containing the address
3678 /// * `address` - The address to get the private key for
3679 ///
3680 /// # Errors
3681 /// Returns an error if the RPC call fails
3682 ///
3683 /// # Examples
3684 /// ```no_run
3685 /// # use amp_rs::ElementsRpc;
3686 /// # #[tokio::main]
3687 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3688 /// let rpc = ElementsRpc::from_env()?;
3689 /// let address = rpc.get_new_address("test_wallet", None).await?;
3690 /// let private_key = rpc.dump_private_key("test_wallet", &address).await?;
3691 /// println!("Private key: {}", private_key);
3692 /// # Ok(())
3693 /// # }
3694 /// ```
3695 pub async fn dump_private_key(
3696 &self,
3697 wallet_name: &str,
3698 address: &str,
3699 ) -> Result<String, AmpError> {
3700 // First load the wallet to ensure it's available
3701 self.load_wallet(wallet_name).await?;
3702
3703 let params = serde_json::json!([address]);
3704
3705 // Create RPC request for dumpprivkey
3706 let request = RpcRequest {
3707 jsonrpc: "1.0".to_string(),
3708 id: "amp-client".to_string(),
3709 method: "dumpprivkey".to_string(),
3710 params,
3711 };
3712
3713 // Use the wallet-specific RPC endpoint
3714 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3715
3716 let response = self
3717 .client
3718 .post(&wallet_url)
3719 .basic_auth(&self.username, Some(&self.password))
3720 .json(&request)
3721 .send()
3722 .await
3723 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3724
3725 if !response.status().is_success() {
3726 return Err(AmpError::rpc(format!(
3727 "RPC request failed with status: {}",
3728 response.status()
3729 )));
3730 }
3731
3732 let rpc_response: RpcResponse<serde_json::Value> = response
3733 .json()
3734 .await
3735 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
3736
3737 if let Some(error) = rpc_response.error {
3738 return Err(AmpError::rpc(format!(
3739 "RPC error dumping private key: {} (code: {})",
3740 error.message, error.code
3741 )));
3742 }
3743
3744 if let Some(result) = rpc_response.result {
3745 if let Some(private_key) = result.as_str() {
3746 tracing::info!("Successfully exported private key for address: {}", address);
3747 return Ok(private_key.to_string());
3748 }
3749 }
3750
3751 Err(AmpError::rpc(format!(
3752 "Failed to dump private key for address '{address}': unexpected response format"
3753 )))
3754 }
3755
3756 /// Creates a descriptor wallet in Elements
3757 ///
3758 /// # Arguments
3759 /// * `wallet_name` - Name for the new wallet
3760 ///
3761 /// # Errors
3762 /// Returns an error if the RPC call fails
3763 ///
3764 /// # Examples
3765 /// ```no_run
3766 /// # use amp_rs::ElementsRpc;
3767 /// # #[tokio::main]
3768 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3769 /// let rpc = ElementsRpc::from_env()?;
3770 /// rpc.create_descriptor_wallet("test_wallet").await?;
3771 /// # Ok(())
3772 /// # }
3773 /// ```
3774 pub async fn create_descriptor_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
3775 let params = serde_json::json!([wallet_name, true]); // true enables descriptors
3776
3777 let _result: serde_json::Value = self.rpc_call("createwallet", params).await?;
3778
3779 tracing::info!("Successfully created descriptor wallet: {}", wallet_name);
3780 Ok(())
3781 }
3782
3783 /// Imports a single descriptor into an Elements wallet
3784 ///
3785 /// This method imports a descriptor that enables the wallet to scan and recognize
3786 /// addresses/UTXOs from a mnemonic. For LWK descriptors with `<0;1>/*` format,
3787 /// a single descriptor covers both receive and change addresses.
3788 ///
3789 /// # Arguments
3790 /// * `wallet_name` - Name of the wallet to import descriptor into
3791 /// * `descriptor` - The descriptor to import
3792 ///
3793 /// # Errors
3794 /// Returns an error if the RPC call fails
3795 ///
3796 /// # Examples
3797 /// ```no_run
3798 /// # use amp_rs::ElementsRpc;
3799 /// # #[tokio::main]
3800 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3801 /// let rpc = ElementsRpc::from_env()?;
3802 /// let descriptor = "ct(slip77(...),elwpkh([...]/84h/1h/0h]tpub.../<0;1>/*))#checksum";
3803 /// rpc.import_descriptor("test_wallet", descriptor).await?;
3804 /// # Ok(())
3805 /// # }
3806 /// ```
3807 pub async fn import_descriptor(
3808 &self,
3809 wallet_name: &str,
3810 descriptor: &str,
3811 ) -> Result<(), AmpError> {
3812 tracing::info!("Importing descriptor into wallet: {}", wallet_name);
3813 tracing::debug!("Descriptor: {}", descriptor);
3814
3815 let descriptors = serde_json::json!([
3816 {
3817 "desc": descriptor,
3818 "timestamp": "now",
3819 "active": true,
3820 "internal": false // For LWK descriptors with <0;1>/*, this covers both chains
3821 }
3822 ]);
3823
3824 // Use -rpcwallet parameter to specify the wallet
3825 let request = RpcRequest {
3826 jsonrpc: "1.0".to_string(),
3827 id: "amp-client".to_string(),
3828 method: "importdescriptors".to_string(),
3829 params: descriptors,
3830 };
3831
3832 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3833
3834 let response = self
3835 .client
3836 .post(&wallet_url)
3837 .basic_auth(&self.username, Some(&self.password))
3838 .json(&request)
3839 .send()
3840 .await
3841 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3842
3843 if !response.status().is_success() {
3844 return Err(AmpError::rpc(format!(
3845 "RPC request failed with status: {}",
3846 response.status()
3847 )));
3848 }
3849
3850 let rpc_response: RpcResponse<serde_json::Value> = response
3851 .json()
3852 .await
3853 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
3854
3855 if let Some(error) = rpc_response.error {
3856 return Err(AmpError::rpc(format!(
3857 "RPC error {}: {}",
3858 error.code, error.message
3859 )));
3860 }
3861
3862 let result = rpc_response
3863 .result
3864 .ok_or_else(|| AmpError::rpc("RPC response missing result field".to_string()))?;
3865
3866 // Check if descriptor was imported successfully
3867 if let Some(results) = result.as_array() {
3868 if let Some(result) = results.first() {
3869 if let Some(success) = result.get("success").and_then(serde_json::Value::as_bool) {
3870 if !success {
3871 let error_msg = result
3872 .get("error")
3873 .and_then(|e| e.get("message"))
3874 .and_then(|m| m.as_str())
3875 .unwrap_or("Unknown error");
3876 return Err(AmpError::rpc(format!(
3877 "Failed to import descriptor: {error_msg}"
3878 )));
3879 }
3880 } else {
3881 return Err(AmpError::rpc(format!(
3882 "Invalid response format for descriptor import: {result:?}"
3883 )));
3884 }
3885 }
3886 } else {
3887 return Err(AmpError::rpc(format!(
3888 "Invalid response format: expected array, got {result:?}"
3889 )));
3890 }
3891
3892 tracing::info!(
3893 "Successfully imported descriptor into wallet: {}",
3894 wallet_name
3895 );
3896 Ok(())
3897 }
3898
3899 /// Imports descriptors into an Elements wallet (legacy method for compatibility)
3900 ///
3901 /// This method imports descriptors that enable the wallet to scan and recognize
3902 /// addresses/UTXOs from a mnemonic. If both descriptors are the same (as with LWK
3903 /// descriptors using `<0;1>/*` format), only one descriptor is imported.
3904 ///
3905 /// # Arguments
3906 /// * `wallet_name` - Name of the wallet to import descriptors into
3907 /// * `receive_descriptor` - The receive descriptor
3908 /// * `change_descriptor` - The change descriptor
3909 ///
3910 /// # Errors
3911 /// Returns an error if the RPC call fails
3912 ///
3913 /// # Examples
3914 /// ```no_run
3915 /// # use amp_rs::ElementsRpc;
3916 /// # #[tokio::main]
3917 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3918 /// let rpc = ElementsRpc::from_env()?;
3919 /// let descriptor = "ct(slip77(...),elwpkh([...]/84h/1h/0h]tpub.../<0;1>/*))#checksum";
3920 /// rpc.import_descriptors("test_wallet", descriptor, descriptor).await?;
3921 /// # Ok(())
3922 /// # }
3923 /// ```
3924 #[allow(clippy::cognitive_complexity)]
3925 pub async fn import_descriptors(
3926 &self,
3927 wallet_name: &str,
3928 receive_descriptor: &str,
3929 change_descriptor: &str,
3930 ) -> Result<(), AmpError> {
3931 // If both descriptors are the same (LWK case), import only once
3932 if receive_descriptor == change_descriptor {
3933 return self
3934 .import_descriptor(wallet_name, receive_descriptor)
3935 .await;
3936 }
3937
3938 tracing::info!(
3939 "Importing separate receive and change descriptors into wallet: {}",
3940 wallet_name
3941 );
3942 tracing::debug!("Receive descriptor: {}", receive_descriptor);
3943 tracing::debug!("Change descriptor: {}", change_descriptor);
3944
3945 let descriptors = serde_json::json!([
3946 {
3947 "desc": receive_descriptor,
3948 "timestamp": "now",
3949 "active": true,
3950 "internal": false
3951 },
3952 {
3953 "desc": change_descriptor,
3954 "timestamp": "now",
3955 "active": true,
3956 "internal": true
3957 }
3958 ]);
3959
3960 // Use -rpcwallet parameter to specify the wallet
3961 let request = RpcRequest {
3962 jsonrpc: "1.0".to_string(),
3963 id: "amp-client".to_string(),
3964 method: "importdescriptors".to_string(),
3965 params: descriptors,
3966 };
3967
3968 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3969
3970 let response = self
3971 .client
3972 .post(&wallet_url)
3973 .basic_auth(&self.username, Some(&self.password))
3974 .json(&request)
3975 .send()
3976 .await
3977 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3978
3979 if !response.status().is_success() {
3980 return Err(AmpError::rpc(format!(
3981 "RPC request failed with status: {}",
3982 response.status()
3983 )));
3984 }
3985
3986 let rpc_response: RpcResponse<serde_json::Value> = response
3987 .json()
3988 .await
3989 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
3990
3991 if let Some(error) = rpc_response.error {
3992 return Err(AmpError::rpc(format!(
3993 "RPC error {}: {}",
3994 error.code, error.message
3995 )));
3996 }
3997
3998 let result = rpc_response
3999 .result
4000 .ok_or_else(|| AmpError::rpc("RPC response missing result field".to_string()))?;
4001
4002 // Check if both descriptors were imported successfully
4003 if let Some(results) = result.as_array() {
4004 for (i, result) in results.iter().enumerate() {
4005 if let Some(success) = result.get("success").and_then(serde_json::Value::as_bool) {
4006 if !success {
4007 let desc_type = if i == 0 { "receive" } else { "change" };
4008 let error_msg = result
4009 .get("error")
4010 .and_then(|e| e.get("message"))
4011 .and_then(|m| m.as_str())
4012 .unwrap_or("Unknown error");
4013 return Err(AmpError::rpc(format!(
4014 "Failed to import {desc_type} descriptor: {error_msg}"
4015 )));
4016 }
4017 } else {
4018 return Err(AmpError::rpc(format!(
4019 "Invalid response format for descriptor import: {result:?}"
4020 )));
4021 }
4022 }
4023 } else {
4024 return Err(AmpError::rpc(format!(
4025 "Invalid response format: expected array, got {result:?}"
4026 )));
4027 }
4028
4029 tracing::info!(
4030 "Successfully imported descriptors into wallet: {}",
4031 wallet_name
4032 );
4033 Ok(())
4034 }
4035
4036 /// Sets up a wallet with descriptors from a mnemonic
4037 ///
4038 /// This is a convenience method that combines wallet creation and descriptor import.
4039 /// It creates a descriptor wallet and imports the receive and change descriptors
4040 /// generated from the provided mnemonic.
4041 ///
4042 /// # Arguments
4043 /// * `wallet_name` - Name for the new wallet
4044 /// * `receive_descriptor` - The receive descriptor (external chain /0/*)
4045 /// * `change_descriptor` - The change descriptor (internal chain /1/*)
4046 ///
4047 /// # Errors
4048 /// Returns an error if wallet creation or descriptor import fails
4049 ///
4050 /// # Examples
4051 /// ```no_run
4052 /// # use amp_rs::ElementsRpc;
4053 /// # #[tokio::main]
4054 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4055 /// let rpc = ElementsRpc::from_env()?;
4056 /// let receive_desc = "wpkh([d34db33f/84h/1h/0h]xprv.../0/*)#checksum";
4057 /// let change_desc = "wpkh([d34db33f/84h/1h/0h]xprv.../1/*)#checksum";
4058 /// rpc.setup_wallet_with_descriptors("test_wallet", receive_desc, change_desc).await?;
4059 /// # Ok(())
4060 /// # }
4061 /// ```
4062 #[allow(clippy::cognitive_complexity)]
4063 pub async fn setup_wallet_with_descriptors(
4064 &self,
4065 wallet_name: &str,
4066 receive_descriptor: &str,
4067 change_descriptor: &str,
4068 ) -> Result<(), AmpError> {
4069 tracing::info!("Setting up wallet with descriptors: {}", wallet_name);
4070
4071 // Try to create the wallet (may fail if it already exists)
4072 match self.create_descriptor_wallet(wallet_name).await {
4073 Ok(()) => {
4074 tracing::info!("Created new descriptor wallet: {}", wallet_name);
4075 }
4076 Err(e) => {
4077 let error_msg = e.to_string();
4078 if error_msg.contains("already exists")
4079 || error_msg.contains("Database already exists")
4080 {
4081 tracing::info!(
4082 "Wallet {} already exists, proceeding with descriptor import",
4083 wallet_name
4084 );
4085 } else {
4086 return Err(e);
4087 }
4088 }
4089 }
4090
4091 // Import the descriptors
4092 self.import_descriptors(wallet_name, receive_descriptor, change_descriptor)
4093 .await?;
4094
4095 tracing::info!(
4096 "Successfully set up wallet with descriptors: {}",
4097 wallet_name
4098 );
4099 Ok(())
4100 }
4101}
4102
4103#[cfg(test)]
4104mod elements_rpc_tests {
4105 use super::*;
4106 use httpmock::prelude::*;
4107 use serial_test::serial;
4108 use std::collections::HashMap;
4109
4110 #[test]
4111 fn test_elements_rpc_new() {
4112 let rpc = ElementsRpc::new(
4113 "http://localhost:18884".to_string(),
4114 "user".to_string(),
4115 "pass".to_string(),
4116 );
4117
4118 assert_eq!(rpc.base_url, "http://localhost:18884");
4119 assert_eq!(rpc.username, "user");
4120 assert_eq!(rpc.password, "pass");
4121 }
4122
4123 #[test]
4124 #[serial]
4125 fn test_elements_rpc_from_env_missing_vars() {
4126 // Store original values to restore later
4127 let original_url = env::var("ELEMENTS_RPC_URL").ok();
4128 let original_user = env::var("ELEMENTS_RPC_USER").ok();
4129 let original_password = env::var("ELEMENTS_RPC_PASSWORD").ok();
4130
4131 // Clear environment variables to test error handling
4132 env::remove_var("ELEMENTS_RPC_URL");
4133 env::remove_var("ELEMENTS_RPC_USER");
4134 env::remove_var("ELEMENTS_RPC_PASSWORD");
4135
4136 let result = ElementsRpc::from_env();
4137 assert!(
4138 result.is_err(),
4139 "ElementsRpc::from_env() should fail when env vars are missing"
4140 );
4141
4142 match result.unwrap_err() {
4143 AmpError::Validation(msg) => {
4144 assert!(
4145 msg.contains("ELEMENTS_RPC_URL"),
4146 "Error message should mention missing ELEMENTS_RPC_URL"
4147 );
4148 }
4149 _ => panic!("Expected validation error"),
4150 }
4151
4152 // Restore original values or keep removed if they weren't set
4153 if let Some(val) = original_url {
4154 env::set_var("ELEMENTS_RPC_URL", val);
4155 }
4156 if let Some(val) = original_user {
4157 env::set_var("ELEMENTS_RPC_USER", val);
4158 }
4159 if let Some(val) = original_password {
4160 env::set_var("ELEMENTS_RPC_PASSWORD", val);
4161 }
4162 }
4163
4164 #[test]
4165 #[serial]
4166 fn test_elements_rpc_from_env_success() {
4167 // Store original values to restore later
4168 let original_url = env::var("ELEMENTS_RPC_URL").ok();
4169 let original_user = env::var("ELEMENTS_RPC_USER").ok();
4170 let original_password = env::var("ELEMENTS_RPC_PASSWORD").ok();
4171
4172 // Set test values
4173 env::set_var("ELEMENTS_RPC_URL", "http://localhost:18884");
4174 env::set_var("ELEMENTS_RPC_USER", "testuser");
4175 env::set_var("ELEMENTS_RPC_PASSWORD", "testpass");
4176
4177 let result = ElementsRpc::from_env();
4178 assert!(
4179 result.is_ok(),
4180 "ElementsRpc::from_env() should succeed when all env vars are set"
4181 );
4182
4183 let rpc = result.unwrap();
4184 assert_eq!(rpc.base_url, "http://localhost:18884");
4185 assert_eq!(rpc.username, "testuser");
4186 assert_eq!(rpc.password, "testpass");
4187
4188 // Restore original values or remove if they weren't set
4189 match original_url {
4190 Some(val) => env::set_var("ELEMENTS_RPC_URL", val),
4191 None => env::remove_var("ELEMENTS_RPC_URL"),
4192 }
4193 match original_user {
4194 Some(val) => env::set_var("ELEMENTS_RPC_USER", val),
4195 None => env::remove_var("ELEMENTS_RPC_USER"),
4196 }
4197 match original_password {
4198 Some(val) => env::set_var("ELEMENTS_RPC_PASSWORD", val),
4199 None => env::remove_var("ELEMENTS_RPC_PASSWORD"),
4200 }
4201 }
4202
4203 #[test]
4204 fn test_elements_rpc_method_signatures() {
4205 // Test that all new methods have correct signatures and can be called
4206 let rpc = ElementsRpc::new(
4207 "http://localhost:18884".to_string(),
4208 "user".to_string(),
4209 "pass".to_string(),
4210 );
4211
4212 // Test that methods exist and have correct signatures (compilation test)
4213 let _: std::pin::Pin<
4214 Box<dyn std::future::Future<Output = Result<Vec<Unspent>, AmpError>> + Send + '_>,
4215 > = Box::pin(rpc.list_unspent(Some("test_asset")));
4216
4217 let inputs = vec![TxInput {
4218 txid: "test_txid".to_string(),
4219 vout: 0,
4220 sequence: None,
4221 }];
4222 let outputs = std::collections::HashMap::new();
4223 let assets = std::collections::HashMap::new();
4224
4225 let _: std::pin::Pin<
4226 Box<dyn std::future::Future<Output = Result<String, AmpError>> + Send + '_>,
4227 > = Box::pin(rpc.create_raw_transaction(inputs, outputs, assets));
4228
4229 let _: std::pin::Pin<
4230 Box<dyn std::future::Future<Output = Result<String, AmpError>> + Send + '_>,
4231 > = Box::pin(rpc.send_raw_transaction("test_hex"));
4232
4233 let _: std::pin::Pin<
4234 Box<dyn std::future::Future<Output = Result<TransactionDetail, AmpError>> + Send + '_>,
4235 > = Box::pin(rpc.get_transaction("test_txid"));
4236 }
4237
4238 // Mock RPC response tests for UTXO and transaction operations
4239
4240 #[tokio::test]
4241 async fn test_get_network_info_success() {
4242 let server = MockServer::start();
4243
4244 let mock_response = serde_json::json!({
4245 "jsonrpc": "1.0",
4246 "id": "amp-client",
4247 "result": {
4248 "version": 220000,
4249 "subversion": "/Liquid:22.0.0/",
4250 "protocolversion": 70016,
4251 "localservices": "0000000000000409",
4252 "localrelay": true,
4253 "timeoffset": 0,
4254 "networkactive": true,
4255 "connections": 8,
4256 "networks": [],
4257 "relayfee": 0.00001000,
4258 "incrementalfee": 0.00001000,
4259 "localaddresses": [],
4260 "warnings": ""
4261 }
4262 });
4263
4264 let mock = server.mock(|when, then| {
4265 when.method(POST)
4266 .path("/")
4267 .header("authorization", "Basic dXNlcjpwYXNz") // base64 of "user:pass"
4268 .json_body(serde_json::json!({
4269 "jsonrpc": "1.0",
4270 "id": "amp-client",
4271 "method": "getnetworkinfo",
4272 "params": []
4273 }));
4274 then.status(200)
4275 .header("content-type", "application/json")
4276 .json_body(mock_response);
4277 });
4278
4279 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
4280 let result = rpc.get_network_info().await;
4281
4282 assert!(result.is_ok());
4283 let network_info = result.unwrap();
4284 assert_eq!(network_info.version, 220000);
4285 assert_eq!(network_info.subversion, "/Liquid:22.0.0/");
4286 assert_eq!(network_info.connections, 8);
4287
4288 mock.assert();
4289 }
4290
4291 #[tokio::test]
4292 async fn test_get_blockchain_info_success() {
4293 let server = MockServer::start();
4294
4295 let mock_response = serde_json::json!({
4296 "jsonrpc": "1.0",
4297 "id": "amp-client",
4298 "result": {
4299 "chain": "liquidregtest",
4300 "blocks": 12345,
4301 "headers": 12345,
4302 "bestblockhash": "abc123def456789",
4303 "difficulty": 4.656542373906925e-10,
4304 "mediantime": 1640995200,
4305 "verificationprogress": 1.0,
4306 "initialblockdownload": false,
4307 "chainwork": "0000000000000000000000000000000000000000000000000000000000003039",
4308 "size_on_disk": 1234567,
4309 "pruned": false,
4310 "softforks": {},
4311 "warnings": ""
4312 }
4313 });
4314
4315 let mock = server.mock(|when, then| {
4316 when.method(POST)
4317 .path("/")
4318 .header("authorization", "Basic dXNlcjpwYXNz")
4319 .json_body(serde_json::json!({
4320 "jsonrpc": "1.0",
4321 "id": "amp-client",
4322 "method": "getblockchaininfo",
4323 "params": []
4324 }));
4325 then.status(200)
4326 .header("content-type", "application/json")
4327 .json_body(mock_response);
4328 });
4329
4330 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
4331 let result = rpc.get_blockchain_info().await;
4332
4333 assert!(result.is_ok());
4334 let blockchain_info = result.unwrap();
4335 assert_eq!(blockchain_info.chain, "liquidregtest");
4336 assert_eq!(blockchain_info.blocks, 12345);
4337 assert_eq!(blockchain_info.bestblockhash, "abc123def456789");
4338
4339 mock.assert();
4340 }
4341
4342 #[tokio::test]
4343 async fn test_list_unspent_with_asset_filter() {
4344 let server = MockServer::start();
4345
4346 let mock_response = serde_json::json!({
4347 "jsonrpc": "1.0",
4348 "id": "amp-client",
4349 "result": [
4350 {
4351 "txid": "abc123def456789",
4352 "vout": 0,
4353 "amount": 100.0,
4354 "asset": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d",
4355 "address": "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
4356 "spendable": true,
4357 "confirmations": 6,
4358 "scriptpubkey": "76a914abc123def456789abc123def456789abc123de88ac"
4359 },
4360 {
4361 "txid": "def456abc123789",
4362 "vout": 1,
4363 "amount": 50.0,
4364 "asset": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d",
4365 "address": "lq1qq3xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
4366 "spendable": true,
4367 "confirmations": 3
4368 }
4369 ]
4370 });
4371
4372 let asset_id = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d";
4373
4374 let mock = server.mock(|when, then| {
4375 when.method(POST)
4376 .path("/")
4377 .header("authorization", "Basic dXNlcjpwYXNz")
4378 .json_body(serde_json::json!({
4379 "jsonrpc": "1.0",
4380 "id": "amp-client",
4381 "method": "listunspent",
4382 "params": [1, 9999999, [], true, {"asset": asset_id}]
4383 }));
4384 then.status(200)
4385 .header("content-type", "application/json")
4386 .json_body(mock_response);
4387 });
4388
4389 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
4390 let result = rpc.list_unspent(Some(asset_id)).await;
4391
4392 assert!(result.is_ok());
4393 let utxos = result.unwrap();
4394 assert_eq!(utxos.len(), 2);
4395 assert_eq!(utxos[0].txid, "abc123def456789");
4396 assert_eq!(utxos[0].amount, 100.0);
4397 assert_eq!(utxos[0].asset, asset_id);
4398 assert_eq!(utxos[1].txid, "def456abc123789");
4399 assert_eq!(utxos[1].amount, 50.0);
4400
4401 mock.assert();
4402 }
4403
4404 #[tokio::test]
4405 async fn test_list_unspent_without_filter() {
4406 let server = MockServer::start();
4407
4408 let mock_response = serde_json::json!({
4409 "jsonrpc": "1.0",
4410 "id": "amp-client",
4411 "result": [
4412 {
4413 "txid": "ghi789jkl012345",
4414 "vout": 0,
4415 "amount": 25.0,
4416 "asset": "different_asset_id",
4417 "address": "lq1qq4xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
4418 "spendable": true,
4419 "confirmations": 10
4420 }
4421 ]
4422 });
4423
4424 let mock = server.mock(|when, then| {
4425 when.method(POST)
4426 .path("/")
4427 .header("authorization", "Basic dXNlcjpwYXNz")
4428 .json_body(serde_json::json!({
4429 "jsonrpc": "1.0",
4430 "id": "amp-client",
4431 "method": "listunspent",
4432 "params": [1, 9999999, [], true]
4433 }));
4434 then.status(200)
4435 .header("content-type", "application/json")
4436 .json_body(mock_response);
4437 });
4438
4439 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
4440 let result = rpc.list_unspent(None).await;
4441
4442 assert!(result.is_ok());
4443 let utxos = result.unwrap();
4444 assert_eq!(utxos.len(), 1);
4445 assert_eq!(utxos[0].txid, "ghi789jkl012345");
4446 assert_eq!(utxos[0].amount, 25.0);
4447
4448 mock.assert();
4449 }
4450
4451 #[tokio::test]
4452 async fn test_create_raw_transaction_success() {
4453 let server = MockServer::start();
4454
4455 let mock_response = serde_json::json!({
4456 "jsonrpc": "1.0",
4457 "id": "amp-client",
4458 "result": "0200000000010abc123def456789abc123def456789abc123def456789abc123def456789abc123def456789000000006b483045022100..."
4459 });
4460
4461 let mock = server.mock(|when, then| {
4462 when.method(POST)
4463 .path("/")
4464 .header("authorization", "Basic dXNlcjpwYXNz")
4465 .json_body(serde_json::json!({
4466 "jsonrpc": "1.0",
4467 "id": "amp-client",
4468 "method": "createrawtransaction",
4469 "params": [
4470 [
4471 {
4472 "txid": "input_txid_123",
4473 "vout": 0,
4474 "sequence": 4294967295u32
4475 }
4476 ],
4477 {
4478 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq": 100.0
4479 },
4480 0,
4481 false,
4482 {
4483 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d"
4484 }
4485 ]
4486 }));
4487 then.status(200)
4488 .header("content-type", "application/json")
4489 .json_body(mock_response);
4490 });
4491
4492 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
4493
4494 let inputs = vec![TxInput {
4495 txid: "input_txid_123".to_string(),
4496 vout: 0,
4497 sequence: Some(0xffffffff),
4498 }];
4499
4500 let mut outputs = HashMap::new();
4501 outputs.insert(
4502 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
4503 100.0,
4504 );
4505
4506 let mut assets = HashMap::new();
4507 assets.insert(
4508 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
4509 "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d".to_string(),
4510 );
4511
4512 let result = rpc.create_raw_transaction(inputs, outputs, assets).await;
4513
4514 assert!(result.is_ok());
4515 let raw_tx = result.unwrap();
4516 assert!(raw_tx.starts_with("0200000000010abc123def456789"));
4517
4518 mock.assert();
4519 }
4520
4521 #[tokio::test]
4522 async fn test_send_raw_transaction_success() {
4523 let server = MockServer::start();
4524
4525 let mock_response = serde_json::json!({
4526 "jsonrpc": "1.0",
4527 "id": "amp-client",
4528 "result": "abc123def456789abc123def456789abc123def456789abc123def456789abc123de"
4529 });
4530
4531 let signed_tx_hex = "0200000000010abc123def456789abc123def456789abc123def456789abc123def456789abc123def456789000000006b483045022100...";
4532
4533 let mock = server.mock(|when, then| {
4534 when.method(POST)
4535 .path("/")
4536 .header("authorization", "Basic dXNlcjpwYXNz")
4537 .json_body(serde_json::json!({
4538 "jsonrpc": "1.0",
4539 "id": "amp-client",
4540 "method": "sendrawtransaction",
4541 "params": [signed_tx_hex]
4542 }));
4543 then.status(200)
4544 .header("content-type", "application/json")
4545 .json_body(mock_response);
4546 });
4547
4548 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
4549 let result = rpc.send_raw_transaction(signed_tx_hex).await;
4550
4551 assert!(result.is_ok());
4552 let txid = result.unwrap();
4553 assert_eq!(
4554 txid,
4555 "abc123def456789abc123def456789abc123def456789abc123def456789abc123de"
4556 );
4557
4558 mock.assert();
4559 }
4560
4561 #[tokio::test]
4562 async fn test_get_transaction_success() {
4563 let server = MockServer::start();
4564
4565 let mock_response = serde_json::json!({
4566 "jsonrpc": "1.0",
4567 "id": "amp-client",
4568 "result": {
4569 "txid": "abc123def456789abc123def456789abc123def456789abc123def456789abc123de",
4570 "confirmations": 6,
4571 "blockheight": 12345,
4572 "hex": "0200000000010abc123def456789...",
4573 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
4574 "blocktime": 1640995200,
4575 "time": 1640995200,
4576 "timereceived": 1640995180
4577 }
4578 });
4579
4580 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
4581
4582 let mock = server.mock(|when, then| {
4583 when.method(POST)
4584 .path("/")
4585 .header("authorization", "Basic dXNlcjpwYXNz")
4586 .json_body(serde_json::json!({
4587 "jsonrpc": "1.0",
4588 "id": "amp-client",
4589 "method": "gettransaction",
4590 "params": [txid, true]
4591 }));
4592 then.status(200)
4593 .header("content-type", "application/json")
4594 .json_body(mock_response);
4595 });
4596
4597 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
4598 let result = rpc.get_transaction(txid).await;
4599
4600 assert!(result.is_ok());
4601 let tx_detail = result.unwrap();
4602 assert_eq!(tx_detail.txid, txid);
4603 assert_eq!(tx_detail.confirmations, 6);
4604 assert_eq!(tx_detail.blockheight, Some(12345));
4605 assert_eq!(tx_detail.blocktime, Some(1640995200));
4606
4607 mock.assert();
4608 }
4609
4610 // Error handling tests
4611
4612 #[tokio::test]
4613 async fn test_rpc_call_network_failure() {
4614 // Use an invalid URL to simulate network failure
4615 let rpc = ElementsRpc::new(
4616 "http://invalid-host:99999".to_string(),
4617 "user".to_string(),
4618 "pass".to_string(),
4619 );
4620
4621 let result = rpc.get_network_info().await;
4622 assert!(result.is_err());
4623
4624 match result.unwrap_err() {
4625 AmpError::Rpc(msg) => {
4626 assert!(msg.contains("Failed to send RPC request"));
4627 }
4628 _ => panic!("Expected RPC error for network failure"),
4629 }
4630 }
4631
4632 #[tokio::test]
4633 async fn test_rpc_call_http_error_status() {
4634 let server = MockServer::start();
4635
4636 let mock = server.mock(|when, then| {
4637 when.method(POST).path("/");
4638 then.status(500)
4639 .header("content-type", "application/json")
4640 .body("Internal Server Error");
4641 });
4642
4643 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
4644 let result = rpc.get_network_info().await;
4645
4646 assert!(result.is_err());
4647 match result.unwrap_err() {
4648 AmpError::Rpc(msg) => {
4649 assert!(msg.contains("RPC request failed with status: 500"));
4650 }
4651 _ => panic!("Expected RPC error for HTTP error status"),
4652 }
4653
4654 mock.assert();
4655 }
4656
4657 #[tokio::test]
4658 async fn test_rpc_call_invalid_json_response() {
4659 let server = MockServer::start();
4660
4661 let mock = server.mock(|when, then| {
4662 when.method(POST).path("/");
4663 then.status(200)
4664 .header("content-type", "application/json")
4665 .body("invalid json response");
4666 });
4667
4668 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
4669 let result = rpc.get_network_info().await;
4670
4671 assert!(result.is_err());
4672 match result.unwrap_err() {
4673 AmpError::Rpc(msg) => {
4674 assert!(msg.contains("Failed to parse RPC response"));
4675 }
4676 _ => panic!("Expected RPC error for invalid JSON"),
4677 }
4678
4679 mock.assert();
4680 }
4681
4682 #[tokio::test]
4683 async fn test_rpc_call_error_response() {
4684 let server = MockServer::start();
4685
4686 let mock_response = serde_json::json!({
4687 "jsonrpc": "1.0",
4688 "id": "amp-client",
4689 "result": null,
4690 "error": {
4691 "code": -32601,
4692 "message": "Method not found"
4693 }
4694 });
4695
4696 let mock = server.mock(|when, then| {
4697 when.method(POST).path("/");
4698 then.status(200)
4699 .header("content-type", "application/json")
4700 .json_body(mock_response);
4701 });
4702
4703 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
4704 let result = rpc.get_network_info().await;
4705
4706 assert!(result.is_err());
4707 match result.unwrap_err() {
4708 AmpError::Rpc(msg) => {
4709 assert!(msg.contains("RPC error -32601: Method not found"));
4710 }
4711 _ => panic!("Expected RPC error for error response"),
4712 }
4713
4714 mock.assert();
4715 }
4716
4717 #[tokio::test]
4718 async fn test_rpc_call_missing_result() {
4719 let server = MockServer::start();
4720
4721 let mock_response = serde_json::json!({
4722 "jsonrpc": "1.0",
4723 "id": "amp-client",
4724 "result": null,
4725 "error": null
4726 });
4727
4728 let mock = server.mock(|when, then| {
4729 when.method(POST).path("/");
4730 then.status(200)
4731 .header("content-type", "application/json")
4732 .json_body(mock_response);
4733 });
4734
4735 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
4736 let result = rpc.get_network_info().await;
4737
4738 assert!(result.is_err());
4739 match result.unwrap_err() {
4740 AmpError::Rpc(msg) => {
4741 assert!(msg.contains("RPC response missing result field"));
4742 }
4743 _ => panic!("Expected RPC error for missing result"),
4744 }
4745
4746 mock.assert();
4747 }
4748
4749 // Authentication tests
4750
4751 #[tokio::test]
4752 async fn test_rpc_authentication_headers() {
4753 let server = MockServer::start();
4754
4755 let mock_response = serde_json::json!({
4756 "jsonrpc": "1.0",
4757 "id": "amp-client",
4758 "result": {
4759 "version": 220000,
4760 "subversion": "/Liquid:22.0.0/",
4761 "protocolversion": 70016,
4762 "localservices": "0000000000000409",
4763 "localrelay": true,
4764 "timeoffset": 0,
4765 "networkactive": true,
4766 "connections": 8,
4767 "networks": [],
4768 "relayfee": 0.00001000,
4769 "incrementalfee": 0.00001000,
4770 "localaddresses": [],
4771 "warnings": ""
4772 }
4773 });
4774
4775 // Test with custom username and password
4776 let mock = server.mock(|when, then| {
4777 when.method(POST)
4778 .path("/")
4779 .header("authorization", "Basic dGVzdHVzZXI6dGVzdHBhc3M=") // base64 of "testuser:testpass"
4780 .json_body(serde_json::json!({
4781 "jsonrpc": "1.0",
4782 "id": "amp-client",
4783 "method": "getnetworkinfo",
4784 "params": []
4785 }));
4786 then.status(200)
4787 .header("content-type", "application/json")
4788 .json_body(mock_response);
4789 });
4790
4791 let rpc = ElementsRpc::new(
4792 server.url("/"),
4793 "testuser".to_string(),
4794 "testpass".to_string(),
4795 );
4796 let result = rpc.get_network_info().await;
4797
4798 assert!(result.is_ok());
4799 mock.assert();
4800 }
4801
4802 // Wallet passphrase tests
4803
4804 #[tokio::test]
4805 async fn test_wallet_passphrase_success() {
4806 let server = MockServer::start();
4807
4808 let mock_response = serde_json::json!({
4809 "jsonrpc": "1.0",
4810 "id": "amp-client",
4811 "result": null
4812 });
4813
4814 let mock = server.mock(|when, then| {
4815 when.method(POST)
4816 .path("/")
4817 .header("authorization", "Basic dXNlcjpwYXNz")
4818 .json_body(serde_json::json!({
4819 "jsonrpc": "1.0",
4820 "id": "amp-client",
4821 "method": "walletpassphrase",
4822 "params": ["my_passphrase", 300]
4823 }));
4824 then.status(200)
4825 .header("content-type", "application/json")
4826 .json_body(mock_response);
4827 });
4828
4829 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
4830 let result = rpc.wallet_passphrase("my_passphrase", 300).await;
4831
4832 assert!(result.is_ok());
4833 mock.assert();
4834 }
4835
4836 // Connection validation tests
4837
4838 #[tokio::test]
4839 async fn test_validate_connection_success() {
4840 let server = MockServer::start();
4841
4842 let mock_response = serde_json::json!({
4843 "jsonrpc": "1.0",
4844 "id": "amp-client",
4845 "result": {
4846 "version": 220000,
4847 "subversion": "/Liquid:22.0.0/",
4848 "protocolversion": 70016,
4849 "localservices": "0000000000000409",
4850 "localrelay": true,
4851 "timeoffset": 0,
4852 "networkactive": true,
4853 "connections": 8,
4854 "networks": [],
4855 "relayfee": 0.00001000,
4856 "incrementalfee": 0.00001000,
4857 "localaddresses": [],
4858 "warnings": ""
4859 }
4860 });
4861
4862 let mock = server.mock(|when, then| {
4863 when.method(POST).path("/");
4864 then.status(200)
4865 .header("content-type", "application/json")
4866 .json_body(mock_response);
4867 });
4868
4869 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
4870 let result = rpc.validate_connection().await;
4871
4872 assert!(result.is_ok());
4873 mock.assert();
4874 }
4875
4876 #[tokio::test]
4877 async fn test_get_node_status_success() {
4878 let server = MockServer::start();
4879
4880 let network_mock_response = serde_json::json!({
4881 "jsonrpc": "1.0",
4882 "id": "amp-client",
4883 "result": {
4884 "version": 220000,
4885 "subversion": "/Liquid:22.0.0/",
4886 "protocolversion": 70016,
4887 "localservices": "0000000000000409",
4888 "localrelay": true,
4889 "timeoffset": 0,
4890 "networkactive": true,
4891 "connections": 8,
4892 "networks": [],
4893 "relayfee": 0.00001000,
4894 "incrementalfee": 0.00001000,
4895 "localaddresses": [],
4896 "warnings": ""
4897 }
4898 });
4899
4900 let blockchain_mock_response = serde_json::json!({
4901 "jsonrpc": "1.0",
4902 "id": "amp-client",
4903 "result": {
4904 "chain": "liquidregtest",
4905 "blocks": 12345,
4906 "headers": 12345,
4907 "bestblockhash": "abc123def456789",
4908 "difficulty": 4.656542373906925e-10,
4909 "mediantime": 1640995200,
4910 "verificationprogress": 1.0,
4911 "initialblockdownload": false,
4912 "chainwork": "0000000000000000000000000000000000000000000000000000000000003039",
4913 "size_on_disk": 1234567,
4914 "pruned": false,
4915 "softforks": {},
4916 "warnings": ""
4917 }
4918 });
4919
4920 let network_mock = server.mock(|when, then| {
4921 when.method(POST).path("/").json_body(serde_json::json!({
4922 "jsonrpc": "1.0",
4923 "id": "amp-client",
4924 "method": "getnetworkinfo",
4925 "params": []
4926 }));
4927 then.status(200)
4928 .header("content-type", "application/json")
4929 .json_body(network_mock_response);
4930 });
4931
4932 let blockchain_mock = server.mock(|when, then| {
4933 when.method(POST).path("/").json_body(serde_json::json!({
4934 "jsonrpc": "1.0",
4935 "id": "amp-client",
4936 "method": "getblockchaininfo",
4937 "params": []
4938 }));
4939 then.status(200)
4940 .header("content-type", "application/json")
4941 .json_body(blockchain_mock_response);
4942 });
4943
4944 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
4945 let result = rpc.get_node_status().await;
4946
4947 assert!(result.is_ok());
4948 let (network_info, blockchain_info) = result.unwrap();
4949 assert_eq!(network_info.version, 220000);
4950 assert_eq!(blockchain_info.blocks, 12345);
4951
4952 network_mock.assert();
4953 blockchain_mock.assert();
4954 }
4955
4956 // Tests for UTXO selection and transaction building logic
4957
4958 #[tokio::test]
4959 async fn test_build_distribution_transaction_zero_amount() {
4960 let rpc = ElementsRpc::new(
4961 "http://localhost:18884".to_string(),
4962 "user".to_string(),
4963 "pass".to_string(),
4964 );
4965
4966 let address_amounts = HashMap::new(); // Empty distribution
4967
4968 let result = rpc
4969 .build_distribution_transaction(
4970 "test_wallet",
4971 "asset_id",
4972 address_amounts,
4973 "change_address",
4974 1.0,
4975 )
4976 .await;
4977
4978 assert!(result.is_err());
4979 match result.unwrap_err() {
4980 AmpError::Validation(msg) => {
4981 assert!(msg.contains("Total distribution amount must be greater than zero"));
4982 }
4983 _ => panic!("Expected validation error for zero distribution amount"),
4984 }
4985 }
4986
4987 #[tokio::test]
4988 async fn test_sign_transaction_validation() {
4989 let rpc = ElementsRpc::new(
4990 "http://localhost:18884".to_string(),
4991 "user".to_string(),
4992 "pass".to_string(),
4993 );
4994
4995 // Mock signer for testing
4996 struct MockSigner {
4997 should_fail: bool,
4998 return_value: String,
4999 }
5000
5001 #[async_trait::async_trait]
5002 impl crate::signer::Signer for MockSigner {
5003 async fn sign_transaction(
5004 &self,
5005 _unsigned_tx: &str,
5006 ) -> Result<String, crate::signer::SignerError> {
5007 if self.should_fail {
5008 Err(crate::signer::SignerError::Lwk(
5009 "Mock signing failure".to_string(),
5010 ))
5011 } else {
5012 // Return a longer hex string to simulate signed transaction (20+ bytes when decoded)
5013 Ok(format!(
5014 "{}deadbeefcafebabe1234567890abcdef",
5015 self.return_value
5016 ))
5017 }
5018 }
5019
5020 fn as_any(&self) -> &dyn std::any::Any {
5021 self
5022 }
5023 }
5024
5025 // Test empty transaction hex
5026 let mock_signer = MockSigner {
5027 should_fail: false,
5028 return_value: "".to_string(),
5029 };
5030 let result = rpc.sign_transaction("", &mock_signer).await;
5031 assert!(result.is_err());
5032 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
5033
5034 // Test odd length hex
5035 let result = rpc.sign_transaction("abc", &mock_signer).await;
5036 assert!(result.is_err());
5037 assert!(result.unwrap_err().to_string().contains("even length"));
5038
5039 // Test invalid hex characters
5040 let result = rpc.sign_transaction("abcg", &mock_signer).await;
5041 assert!(result.is_err());
5042 assert!(result
5043 .unwrap_err()
5044 .to_string()
5045 .contains("invalid hex characters"));
5046
5047 // Test signer failure
5048 let mock_signer = MockSigner {
5049 should_fail: true,
5050 return_value: "".to_string(),
5051 };
5052 let result = rpc.sign_transaction("abcd", &mock_signer).await;
5053 assert!(result.is_err());
5054 assert!(result
5055 .unwrap_err()
5056 .to_string()
5057 .contains("Mock signing failure"));
5058
5059 // Test successful signing
5060 let mock_signer = MockSigner {
5061 should_fail: false,
5062 return_value: "abcd".to_string(),
5063 };
5064 let result = rpc.sign_transaction("abcd", &mock_signer).await;
5065 if result.is_err() {
5066 println!("Error: {}", result.as_ref().unwrap_err());
5067 }
5068 assert!(result.is_ok());
5069 assert_eq!(result.unwrap(), "abcddeadbeefcafebabe1234567890abcdef");
5070 }
5071
5072 #[tokio::test]
5073 async fn test_sign_transaction_validation_edge_cases() {
5074 let rpc = ElementsRpc::new(
5075 "http://localhost:18884".to_string(),
5076 "user".to_string(),
5077 "pass".to_string(),
5078 );
5079
5080 // Mock signer that returns invalid responses
5081 struct BadMockSigner {
5082 return_empty: bool,
5083 return_odd_length: bool,
5084 return_invalid_hex: bool,
5085 return_shorter: bool,
5086 }
5087
5088 #[async_trait::async_trait]
5089 impl crate::signer::Signer for BadMockSigner {
5090 async fn sign_transaction(
5091 &self,
5092 unsigned_tx: &str,
5093 ) -> Result<String, crate::signer::SignerError> {
5094 if self.return_empty {
5095 Ok("".to_string())
5096 } else if self.return_odd_length {
5097 Ok("abc".to_string())
5098 } else if self.return_invalid_hex {
5099 Ok("abcg".to_string())
5100 } else if self.return_shorter {
5101 Ok("ab".to_string()) // Shorter than input "abcd"
5102 } else {
5103 Ok(format!("{}deadbeef", unsigned_tx))
5104 }
5105 }
5106
5107 fn as_any(&self) -> &dyn std::any::Any {
5108 self
5109 }
5110 }
5111
5112 // Test signer returning empty string
5113 let bad_signer = BadMockSigner {
5114 return_empty: true,
5115 return_odd_length: false,
5116 return_invalid_hex: false,
5117 return_shorter: false,
5118 };
5119 let result = rpc.sign_transaction("abcd", &bad_signer).await;
5120 assert!(result.is_err());
5121 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
5122
5123 // Test signer returning odd length hex
5124 let bad_signer = BadMockSigner {
5125 return_empty: false,
5126 return_odd_length: true,
5127 return_invalid_hex: false,
5128 return_shorter: false,
5129 };
5130 let result = rpc.sign_transaction("abcd", &bad_signer).await;
5131 assert!(result.is_err());
5132 assert!(result.unwrap_err().to_string().contains("even length"));
5133
5134 // Test signer returning invalid hex
5135 let bad_signer = BadMockSigner {
5136 return_empty: false,
5137 return_odd_length: false,
5138 return_invalid_hex: true,
5139 return_shorter: false,
5140 };
5141 let result = rpc.sign_transaction("abcd", &bad_signer).await;
5142 assert!(result.is_err());
5143 assert!(result
5144 .unwrap_err()
5145 .to_string()
5146 .contains("invalid hex characters"));
5147
5148 // Test signer returning shorter transaction (invalid)
5149 let bad_signer = BadMockSigner {
5150 return_empty: false,
5151 return_odd_length: false,
5152 return_invalid_hex: false,
5153 return_shorter: true,
5154 };
5155 let result = rpc.sign_transaction("abcd", &bad_signer).await;
5156 assert!(result.is_err());
5157 assert!(result
5158 .unwrap_err()
5159 .to_string()
5160 .contains("shorter than unsigned transaction"));
5161 }
5162
5163 #[tokio::test]
5164 async fn test_sign_transaction_minimum_size_validation() {
5165 let rpc = ElementsRpc::new(
5166 "http://localhost:18884".to_string(),
5167 "user".to_string(),
5168 "pass".to_string(),
5169 );
5170
5171 // Mock signer that returns very small transactions
5172 struct TinyMockSigner;
5173
5174 #[async_trait::async_trait]
5175 impl crate::signer::Signer for TinyMockSigner {
5176 async fn sign_transaction(
5177 &self,
5178 _unsigned_tx: &str,
5179 ) -> Result<String, crate::signer::SignerError> {
5180 Ok("abcd".to_string()) // Only 2 bytes when decoded
5181 }
5182
5183 fn as_any(&self) -> &dyn std::any::Any {
5184 self
5185 }
5186 }
5187
5188 let tiny_signer = TinyMockSigner;
5189 let result = rpc.sign_transaction("abcd", &tiny_signer).await;
5190 assert!(result.is_err());
5191 let error_msg = result.unwrap_err().to_string();
5192 assert!(error_msg.contains("minimum size"));
5193 assert!(error_msg.contains("minimum is 10 bytes"));
5194 }
5195
5196 #[tokio::test]
5197 async fn test_sign_transaction_success_case() {
5198 let rpc = ElementsRpc::new(
5199 "http://localhost:18884".to_string(),
5200 "user".to_string(),
5201 "pass".to_string(),
5202 );
5203
5204 // Mock signer that returns a valid signed transaction
5205 struct GoodMockSigner;
5206
5207 #[async_trait::async_trait]
5208 impl crate::signer::Signer for GoodMockSigner {
5209 async fn sign_transaction(
5210 &self,
5211 unsigned_tx: &str,
5212 ) -> Result<String, crate::signer::SignerError> {
5213 // Return a longer valid hex string (20+ bytes when decoded)
5214 Ok(format!("{}deadbeefcafebabe1234567890abcdef", unsigned_tx))
5215 }
5216
5217 fn as_any(&self) -> &dyn std::any::Any {
5218 self
5219 }
5220 }
5221
5222 let good_signer = GoodMockSigner;
5223
5224 // Test with a reasonable sized unsigned transaction
5225 let unsigned_tx = "0200000000010123456789abcdef"; // 14 bytes when decoded
5226 let result = rpc.sign_transaction(unsigned_tx, &good_signer).await;
5227
5228 assert!(result.is_ok());
5229 let signed_tx = result.unwrap();
5230 assert!(signed_tx.starts_with(unsigned_tx));
5231 assert!(signed_tx.len() > unsigned_tx.len());
5232 assert!(signed_tx.contains("deadbeefcafebabe"));
5233 }
5234
5235 #[tokio::test]
5236 async fn test_sign_and_broadcast_transaction_mock() {
5237 // Create a mock server for testing the broadcast part
5238 let server = MockServer::start();
5239
5240 // Mock the RPC response for sendrawtransaction
5241 let mock = server.mock(|when, then| {
5242 when.method(POST).path("/").json_body(serde_json::json!({
5243 "jsonrpc": "1.0",
5244 "id": "amp-client",
5245 "method": "sendrawtransaction",
5246 "params": ["0200000000010123456789abcdefdeadbeefcafebabe1234567890abcdef"]
5247 }));
5248 then.status(200).json_body(serde_json::json!({
5249 "jsonrpc": "1.0",
5250 "id": "amp-client",
5251 "result": "abc123def456789",
5252 "error": null
5253 }));
5254 });
5255
5256 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5257
5258 // Mock signer for testing
5259 struct TestMockSigner;
5260
5261 #[async_trait::async_trait]
5262 impl crate::signer::Signer for TestMockSigner {
5263 async fn sign_transaction(
5264 &self,
5265 unsigned_tx: &str,
5266 ) -> Result<String, crate::signer::SignerError> {
5267 Ok(format!("{}deadbeefcafebabe1234567890abcdef", unsigned_tx))
5268 }
5269
5270 fn as_any(&self) -> &dyn std::any::Any {
5271 self
5272 }
5273 }
5274
5275 let signer = TestMockSigner;
5276 let unsigned_tx = "0200000000010123456789abcdef";
5277
5278 let result = rpc
5279 .sign_and_broadcast_transaction(unsigned_tx, &signer)
5280 .await;
5281
5282 assert!(result.is_ok());
5283 assert_eq!(result.unwrap(), "abc123def456789");
5284
5285 // Verify the mock was called
5286 mock.assert();
5287 }
5288
5289 #[tokio::test]
5290 async fn test_sign_and_broadcast_transaction_signing_failure() {
5291 let rpc = ElementsRpc::new(
5292 "http://localhost:18884".to_string(),
5293 "user".to_string(),
5294 "pass".to_string(),
5295 );
5296
5297 // Mock signer that fails
5298 struct FailingSigner;
5299
5300 #[async_trait::async_trait]
5301 impl crate::signer::Signer for FailingSigner {
5302 async fn sign_transaction(
5303 &self,
5304 _unsigned_tx: &str,
5305 ) -> Result<String, crate::signer::SignerError> {
5306 Err(crate::signer::SignerError::Lwk(
5307 "Signing failed".to_string(),
5308 ))
5309 }
5310
5311 fn as_any(&self) -> &dyn std::any::Any {
5312 self
5313 }
5314 }
5315
5316 let failing_signer = FailingSigner;
5317 let result = rpc
5318 .sign_and_broadcast_transaction("abcd", &failing_signer)
5319 .await;
5320
5321 assert!(result.is_err());
5322 let error_msg = result.unwrap_err().to_string();
5323 // The error should be a Signer error containing the original failure message
5324 assert!(error_msg.contains("Signer error"));
5325 assert!(error_msg.contains("Signing failed"));
5326 }
5327
5328 #[tokio::test]
5329 async fn test_sign_and_broadcast_transaction_broadcast_failure() {
5330 // Create a mock server that returns an error for broadcast
5331 let server = MockServer::start();
5332
5333 let mock = server.mock(|when, then| {
5334 when.method(POST).path("/");
5335 then.status(200).json_body(serde_json::json!({
5336 "jsonrpc": "1.0",
5337 "id": "amp-client",
5338 "result": null,
5339 "error": {
5340 "code": -26,
5341 "message": "Transaction rejected"
5342 }
5343 }));
5344 });
5345
5346 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5347
5348 // Mock signer that succeeds
5349 struct WorkingSigner;
5350
5351 #[async_trait::async_trait]
5352 impl crate::signer::Signer for WorkingSigner {
5353 async fn sign_transaction(
5354 &self,
5355 unsigned_tx: &str,
5356 ) -> Result<String, crate::signer::SignerError> {
5357 Ok(format!("{}deadbeefcafebabe1234567890abcdef", unsigned_tx))
5358 }
5359
5360 fn as_any(&self) -> &dyn std::any::Any {
5361 self
5362 }
5363 }
5364
5365 let working_signer = WorkingSigner;
5366 let unsigned_tx = "0200000000010123456789abcdef";
5367
5368 let result = rpc
5369 .sign_and_broadcast_transaction(unsigned_tx, &working_signer)
5370 .await;
5371
5372 assert!(result.is_err());
5373 let error_msg = result.unwrap_err().to_string();
5374 assert!(error_msg.contains("Failed during transaction broadcast phase"));
5375 assert!(error_msg.contains("Transaction rejected"));
5376
5377 mock.assert();
5378 }
5379
5380 #[tokio::test]
5381 async fn test_wait_for_confirmations_success() {
5382 let server = MockServer::start();
5383
5384 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
5385
5386 // First call returns 1 confirmation (not enough)
5387 let _mock_response_1 = serde_json::json!({
5388 "jsonrpc": "1.0",
5389 "id": "amp-client",
5390 "result": {
5391 "txid": txid,
5392 "confirmations": 1,
5393 "blockheight": 12345,
5394 "hex": "0200000000010abc123def456789...",
5395 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
5396 "blocktime": 1640995200,
5397 "time": 1640995200,
5398 "timereceived": 1640995180
5399 }
5400 });
5401
5402 // Second call returns 2 confirmations (sufficient)
5403 let mock_response_2 = serde_json::json!({
5404 "jsonrpc": "1.0",
5405 "id": "amp-client",
5406 "result": {
5407 "txid": txid,
5408 "confirmations": 2,
5409 "blockheight": 12345,
5410 "hex": "0200000000010abc123def456789...",
5411 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
5412 "blocktime": 1640995200,
5413 "time": 1640995200,
5414 "timereceived": 1640995180
5415 }
5416 });
5417
5418 // Create a mock that returns 2 confirmations immediately (simpler test)
5419 let mock = server.mock(|when, then| {
5420 when.method(POST)
5421 .path("/")
5422 .header("authorization", "Basic dXNlcjpwYXNz")
5423 .json_body(serde_json::json!({
5424 "jsonrpc": "1.0",
5425 "id": "amp-client",
5426 "method": "gettransaction",
5427 "params": [txid, true]
5428 }));
5429 then.status(200)
5430 .header("content-type", "application/json")
5431 .json_body(mock_response_2); // Return sufficient confirmations immediately
5432 });
5433
5434 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5435
5436 // Use fast polling (1 second) for testing
5437 let result = rpc
5438 .wait_for_confirmations_with_interval(txid, Some(2), Some(1), Some(1))
5439 .await;
5440
5441 assert!(result.is_ok());
5442 let tx_detail = result.unwrap();
5443 assert_eq!(tx_detail.confirmations, 2);
5444 assert_eq!(tx_detail.txid, txid);
5445
5446 // Mock should have been called once
5447 mock.assert();
5448 }
5449
5450 #[tokio::test]
5451 async fn test_wait_for_confirmations_timeout() {
5452 let server = MockServer::start();
5453
5454 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
5455
5456 // Always return insufficient confirmations
5457 let mock_response = serde_json::json!({
5458 "jsonrpc": "1.0",
5459 "id": "amp-client",
5460 "result": {
5461 "txid": txid,
5462 "confirmations": 1,
5463 "blockheight": 12345,
5464 "hex": "0200000000010abc123def456789...",
5465 "blockhash": null,
5466 "blocktime": null,
5467 "time": null,
5468 "timereceived": null
5469 }
5470 });
5471
5472 let _mock = server.mock(|when, then| {
5473 when.method(POST)
5474 .path("/")
5475 .header("authorization", "Basic dXNlcjpwYXNz")
5476 .json_body(serde_json::json!({
5477 "jsonrpc": "1.0",
5478 "id": "amp-client",
5479 "method": "gettransaction",
5480 "params": [txid, true]
5481 }));
5482 then.status(200)
5483 .header("content-type", "application/json")
5484 .json_body(mock_response);
5485 });
5486
5487 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5488
5489 // Use a very short timeout for testing (0 = 3 seconds) and fast polling (1 second)
5490 let result = rpc
5491 .wait_for_confirmations_with_interval(txid, Some(2), Some(0), Some(1))
5492 .await;
5493
5494 assert!(result.is_err());
5495 match result.unwrap_err() {
5496 AmpError::Timeout(msg) => {
5497 assert!(msg.contains("Timeout waiting for confirmations"));
5498 assert!(msg.contains(txid));
5499 assert!(msg.contains("retry confirmation"));
5500 }
5501 _ => panic!("Expected timeout error"),
5502 }
5503
5504 // Mock will be called multiple times during the timeout period
5505 // We don't assert on the exact number since it depends on timing
5506 }
5507
5508 #[tokio::test]
5509 async fn test_wait_for_confirmations_immediate_success() {
5510 let server = MockServer::start();
5511
5512 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
5513
5514 // Transaction already has sufficient confirmations
5515 let mock_response = serde_json::json!({
5516 "jsonrpc": "1.0",
5517 "id": "amp-client",
5518 "result": {
5519 "txid": txid,
5520 "confirmations": 5,
5521 "blockheight": 12345,
5522 "hex": "0200000000010abc123def456789...",
5523 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
5524 "blocktime": 1640995200,
5525 "time": 1640995200,
5526 "timereceived": 1640995180
5527 }
5528 });
5529
5530 let mock = server.mock(|when, then| {
5531 when.method(POST)
5532 .path("/")
5533 .header("authorization", "Basic dXNlcjpwYXNz")
5534 .json_body(serde_json::json!({
5535 "jsonrpc": "1.0",
5536 "id": "amp-client",
5537 "method": "gettransaction",
5538 "params": [txid, true]
5539 }));
5540 then.status(200)
5541 .header("content-type", "application/json")
5542 .json_body(mock_response);
5543 });
5544
5545 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5546
5547 let result = rpc.wait_for_confirmations(txid, Some(2), Some(10)).await;
5548
5549 assert!(result.is_ok());
5550 let tx_detail = result.unwrap();
5551 assert_eq!(tx_detail.confirmations, 5);
5552 assert_eq!(tx_detail.txid, txid);
5553
5554 // Should only need one call since confirmations are already sufficient
5555 mock.assert();
5556 }
5557}
5558
5559/// Configuration for retry behavior in API requests
5560#[derive(Debug, Clone)]
5561pub struct RetryConfig {
5562 /// Maximum number of retry attempts
5563 pub max_attempts: u32,
5564 /// Base delay in milliseconds for exponential backoff
5565 pub base_delay_ms: u64,
5566 /// Maximum delay in milliseconds to cap exponential backoff
5567 pub max_delay_ms: u64,
5568 /// Request timeout in seconds
5569 pub timeout_seconds: u64,
5570}
5571
5572impl Default for RetryConfig {
5573 fn default() -> Self {
5574 Self {
5575 max_attempts: 3,
5576 base_delay_ms: 1000,
5577 max_delay_ms: 30_000,
5578 timeout_seconds: 10,
5579 }
5580 }
5581}
5582
5583impl RetryConfig {
5584 /// Creates a `RetryConfig` from environment variables with default fallbacks
5585 ///
5586 /// Environment variables:
5587 /// - `API_RETRY_MAX_ATTEMPTS`: Maximum retry attempts (default: 3)
5588 /// - `API_RETRY_BASE_DELAY_MS`: Base delay in milliseconds (default: 1000)
5589 /// - `API_RETRY_MAX_DELAY_MS`: Maximum delay in milliseconds (default: 30000)
5590 /// - `API_REQUEST_TIMEOUT_SECONDS`: Request timeout in seconds (default: 10)
5591 ///
5592 /// # Errors
5593 ///
5594 /// Returns an error if any environment variable contains an invalid value
5595 pub fn from_env() -> Result<Self, Error> {
5596 let max_attempts = match env::var("API_RETRY_MAX_ATTEMPTS") {
5597 Ok(val) => val.parse::<u32>().map_err(|e| {
5598 Error::InvalidRetryConfig(format!("Invalid API_RETRY_MAX_ATTEMPTS: {e}"))
5599 })?,
5600 Err(_) => 3,
5601 };
5602
5603 let base_delay_ms = match env::var("API_RETRY_BASE_DELAY_MS") {
5604 Ok(val) => val.parse::<u64>().map_err(|e| {
5605 Error::InvalidRetryConfig(format!("Invalid API_RETRY_BASE_DELAY_MS: {e}"))
5606 })?,
5607 Err(_) => 1000,
5608 };
5609
5610 let max_delay_ms = match env::var("API_RETRY_MAX_DELAY_MS") {
5611 Ok(val) => val.parse::<u64>().map_err(|e| {
5612 Error::InvalidRetryConfig(format!("Invalid API_RETRY_MAX_DELAY_MS: {e}"))
5613 })?,
5614 Err(_) => 30_000,
5615 };
5616
5617 let timeout_seconds = match env::var("API_REQUEST_TIMEOUT_SECONDS") {
5618 Ok(val) => val.parse::<u64>().map_err(|e| {
5619 Error::InvalidRetryConfig(format!("Invalid API_REQUEST_TIMEOUT_SECONDS: {e}"))
5620 })?,
5621 Err(_) => 10,
5622 };
5623
5624 // Validate configuration
5625 if max_attempts == 0 {
5626 return Err(Error::InvalidRetryConfig(
5627 "max_attempts must be greater than 0".to_string(),
5628 ));
5629 }
5630 if base_delay_ms == 0 {
5631 return Err(Error::InvalidRetryConfig(
5632 "base_delay_ms must be greater than 0".to_string(),
5633 ));
5634 }
5635 if max_delay_ms < base_delay_ms {
5636 return Err(Error::InvalidRetryConfig(
5637 "max_delay_ms must be greater than or equal to base_delay_ms".to_string(),
5638 ));
5639 }
5640 if timeout_seconds == 0 {
5641 return Err(Error::InvalidRetryConfig(
5642 "timeout_seconds must be greater than 0".to_string(),
5643 ));
5644 }
5645
5646 Ok(Self {
5647 max_attempts,
5648 base_delay_ms,
5649 max_delay_ms,
5650 timeout_seconds,
5651 })
5652 }
5653
5654 /// Creates a `RetryConfig` optimized for test environments
5655 ///
5656 /// Uses reduced values for faster test execution:
5657 /// - 2 retry attempts
5658 /// - 500ms base delay
5659 /// - 5000ms max delay
5660 /// - 5 second timeout
5661 #[must_use]
5662 pub const fn for_tests() -> Self {
5663 Self {
5664 max_attempts: 2,
5665 base_delay_ms: 500,
5666 max_delay_ms: 5000,
5667 timeout_seconds: 5,
5668 }
5669 }
5670
5671 /// Sets a custom timeout value
5672 #[must_use]
5673 pub const fn with_timeout(mut self, timeout_seconds: u64) -> Self {
5674 self.timeout_seconds = timeout_seconds;
5675 self
5676 }
5677
5678 /// Sets custom max attempts
5679 #[must_use]
5680 pub const fn with_max_attempts(mut self, max_attempts: u32) -> Self {
5681 self.max_attempts = max_attempts;
5682 self
5683 }
5684
5685 /// Sets custom base delay
5686 #[must_use]
5687 pub const fn with_base_delay_ms(mut self, base_delay_ms: u64) -> Self {
5688 self.base_delay_ms = base_delay_ms;
5689 self
5690 }
5691
5692 /// Sets custom max delay
5693 #[must_use]
5694 pub const fn with_max_delay_ms(mut self, max_delay_ms: u64) -> Self {
5695 self.max_delay_ms = max_delay_ms;
5696 self
5697 }
5698}
5699
5700/// HTTP client with sophisticated retry logic and exponential backoff
5701#[derive(Debug, Clone)]
5702pub struct RetryClient {
5703 client: Client,
5704 config: RetryConfig,
5705}
5706
5707impl RetryClient {
5708 /// Creates a new `RetryClient` with the given configuration
5709 #[must_use]
5710 pub fn new(config: RetryConfig) -> Self {
5711 Self {
5712 client: Client::new(),
5713 config,
5714 }
5715 }
5716
5717 /// Creates a new `RetryClient` with default configuration
5718 #[must_use]
5719 pub fn with_default_config() -> Self {
5720 Self::new(RetryConfig::default())
5721 }
5722
5723 /// Creates a new `RetryClient` with test-optimized configuration
5724 #[must_use]
5725 pub fn for_tests() -> Self {
5726 Self::new(RetryConfig::for_tests())
5727 }
5728
5729 /// Executes an HTTP request with retry logic and exponential backoff
5730 ///
5731 /// # Arguments
5732 /// * `request_builder` - A function that creates the request builder
5733 ///
5734 /// # Returns
5735 /// The response if successful, or an error after all retries are exhausted
5736 ///
5737 /// # Errors
5738 /// Returns `TokenError::Timeout` if the request times out
5739 /// Returns `TokenError::RateLimited` if rate limited and retries are exhausted
5740 /// Returns `TokenError::ObtainFailed` if all retry attempts fail
5741 #[allow(clippy::cognitive_complexity)]
5742 pub async fn execute_with_retry<F>(
5743 &self,
5744 request_builder: F,
5745 ) -> Result<reqwest::Response, TokenError>
5746 where
5747 F: Fn() -> reqwest::RequestBuilder + Send + Sync,
5748 {
5749 let mut last_error = String::new();
5750 let mut attempt = 0;
5751
5752 while attempt < self.config.max_attempts {
5753 attempt += 1;
5754
5755 // Create the request with timeout
5756 let request =
5757 request_builder().timeout(StdDuration::from_secs(self.config.timeout_seconds));
5758
5759 // Execute the request
5760 match request.send().await {
5761 Ok(response) => {
5762 let status = response.status();
5763
5764 // Handle rate limiting (429 Too Many Requests)
5765 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
5766 let retry_after = Self::extract_retry_after(&response).unwrap_or(60);
5767
5768 tracing::warn!(
5769 "Rate limited (429) on attempt {}/{}. Retry after {} seconds",
5770 attempt,
5771 self.config.max_attempts,
5772 retry_after
5773 );
5774
5775 // If this is our last attempt, return the rate limit error
5776 if attempt >= self.config.max_attempts {
5777 return Err(TokenError::rate_limited(retry_after));
5778 }
5779
5780 // Wait for the rate limit period (or our max delay, whichever is smaller)
5781 let delay_ms = std::cmp::min(retry_after * 1000, self.config.max_delay_ms);
5782 sleep(StdDuration::from_millis(delay_ms)).await;
5783 continue;
5784 }
5785
5786 // Handle other client errors (4xx) - these are generally not retryable
5787 if status.is_client_error() && status != reqwest::StatusCode::TOO_MANY_REQUESTS
5788 {
5789 last_error = format!("Client error: {status}");
5790 tracing::error!("Non-retryable client error: {}", status);
5791 break;
5792 }
5793
5794 // Handle server errors (5xx) - these are retryable
5795 if status.is_server_error() {
5796 last_error = format!("Server error: {status}");
5797 tracing::warn!(
5798 "Server error {} on attempt {}/{}",
5799 status,
5800 attempt,
5801 self.config.max_attempts
5802 );
5803
5804 if attempt < self.config.max_attempts {
5805 let delay = self.calculate_backoff_delay(attempt);
5806 sleep(delay).await;
5807 continue;
5808 }
5809 break;
5810 }
5811
5812 // Success case
5813 return Ok(response);
5814 }
5815 Err(e) => {
5816 last_error = e.to_string();
5817
5818 // Check if this is a timeout error
5819 if e.is_timeout() {
5820 tracing::warn!(
5821 "Request timeout on attempt {}/{}",
5822 attempt,
5823 self.config.max_attempts
5824 );
5825
5826 if attempt >= self.config.max_attempts {
5827 return Err(TokenError::timeout(self.config.timeout_seconds));
5828 }
5829 } else {
5830 tracing::warn!(
5831 "Request failed on attempt {}/{}: {}",
5832 attempt,
5833 self.config.max_attempts,
5834 e
5835 );
5836 }
5837
5838 // If we have more attempts, wait and retry
5839 if attempt < self.config.max_attempts {
5840 let delay = self.calculate_backoff_delay(attempt);
5841 sleep(delay).await;
5842 }
5843 }
5844 }
5845 }
5846
5847 // All retries exhausted
5848 Err(TokenError::obtain_failed(attempt, last_error))
5849 }
5850
5851 /// Calculates the delay for exponential backoff with jitter
5852 ///
5853 /// Uses the formula: `min(base_delay * 2^(attempt-1) + jitter, max_delay)`
5854 /// where jitter is a random value between 0 and `base_delay/2`
5855 pub fn calculate_backoff_delay(&self, attempt: u32) -> StdDuration {
5856 use rand::Rng;
5857
5858 let base_delay = self.config.base_delay_ms;
5859 let max_delay = self.config.max_delay_ms;
5860
5861 // Calculate exponential backoff: base_delay * 2^(attempt-1)
5862 let exponential_delay = base_delay * 2_u64.pow(attempt.saturating_sub(1));
5863
5864 // Add jitter (random value between 0 and base_delay/2)
5865 let jitter = rand::thread_rng().gen_range(0..=base_delay / 2);
5866 let total_delay = exponential_delay + jitter;
5867
5868 // Cap at max_delay
5869 let final_delay = std::cmp::min(total_delay, max_delay);
5870
5871 tracing::debug!(
5872 "Calculated backoff delay for attempt {}: {}ms (exponential: {}ms, jitter: {}ms, capped at: {}ms)",
5873 attempt,
5874 final_delay,
5875 exponential_delay,
5876 jitter,
5877 max_delay
5878 );
5879
5880 StdDuration::from_millis(final_delay)
5881 }
5882
5883 /// Extracts the Retry-After header value from a 429 response
5884 ///
5885 /// Returns the number of seconds to wait, or None if the header is not present
5886 /// or cannot be parsed
5887 fn extract_retry_after(response: &reqwest::Response) -> Option<u64> {
5888 response
5889 .headers()
5890 .get("retry-after")
5891 .and_then(|value| value.to_str().ok())
5892 .and_then(|s| s.parse::<u64>().ok())
5893 }
5894
5895 /// Gets the underlying reqwest client
5896 #[must_use]
5897 pub const fn client(&self) -> &Client {
5898 &self.client
5899 }
5900
5901 /// Gets the retry configuration
5902 #[must_use]
5903 pub const fn config(&self) -> &RetryConfig {
5904 &self.config
5905 }
5906}
5907
5908/// Singleton instance of the `TokenManager` for shared token storage across all `ApiClient` instances
5909static GLOBAL_TOKEN_MANAGER: OnceCell<Arc<TokenManager>> = OnceCell::const_new();
5910
5911/// Core token manager with proactive refresh and secure storage
5912#[derive(Debug)]
5913pub struct TokenManager {
5914 pub token_data: Arc<Mutex<Option<TokenData>>>,
5915 pub retry_client: RetryClient,
5916 base_url: Url,
5917 /// Semaphore to ensure only one token operation (obtain/refresh) happens at a time
5918 /// This prevents race conditions where multiple threads try to refresh/obtain simultaneously
5919 token_operation_semaphore: Arc<Semaphore>,
5920}
5921
5922impl TokenManager {
5923 /// Gets the global singleton instance of `TokenManager`
5924 ///
5925 /// This ensures all `ApiClient` instances share the same token storage,
5926 /// preventing multiple token acquisition attempts in concurrent tests.
5927 ///
5928 /// # Errors
5929 /// Returns an error if the `TokenManager` cannot be initialized
5930 pub async fn get_global_instance() -> Result<Arc<Self>, Error> {
5931 let manager = GLOBAL_TOKEN_MANAGER
5932 .get_or_try_init(|| async {
5933 let config = RetryConfig::from_env()?;
5934 let base_url = get_amp_api_base_url()?;
5935 let manager = Self::with_config_and_base_url(config, base_url).await?;
5936 Ok::<Arc<Self>, Error>(Arc::new(manager))
5937 })
5938 .await?;
5939
5940 Ok(manager.clone())
5941 }
5942
5943 /// Creates a new `TokenManager` with default configuration
5944 ///
5945 /// # Errors
5946 /// Returns an error if the base URL cannot be obtained from environment variables
5947 pub async fn new() -> Result<Self, Error> {
5948 let config = RetryConfig::from_env()?;
5949 Self::with_config(config).await
5950 }
5951
5952 /// Creates a new `TokenManager` with the specified retry configuration
5953 ///
5954 /// # Errors
5955 /// Returns an error if the base URL cannot be obtained from environment variables
5956 pub async fn with_config(config: RetryConfig) -> Result<Self, Error> {
5957 let base_url = get_amp_api_base_url()?;
5958 Self::with_config_and_base_url(config, base_url).await
5959 }
5960
5961 /// Creates a new `TokenManager` with the specified configuration and base URL (for testing)
5962 ///
5963 /// # Errors
5964 /// This method is infallible but returns Result for API consistency
5965 pub async fn with_config_and_base_url(
5966 config: RetryConfig,
5967 base_url: Url,
5968 ) -> Result<Self, Error> {
5969 let manager = Self {
5970 token_data: Arc::new(Mutex::new(None)),
5971 retry_client: RetryClient::new(config),
5972 base_url,
5973 token_operation_semaphore: Arc::new(Semaphore::new(1)),
5974 };
5975
5976 // Load token from disk if persistence is enabled
5977 if Self::should_persist_tokens() {
5978 if let Ok(Some(token_data)) = manager.load_token_from_disk().await {
5979 *manager.token_data.lock().await = Some(token_data);
5980 tracing::info!("Token loaded from disk during initialization");
5981 }
5982 }
5983
5984 Ok(manager)
5985 }
5986
5987 /// Creates a new `TokenManager` with a pre-set mock token (for testing)
5988 ///
5989 /// # Errors
5990 /// This method is infallible but returns Result for API consistency
5991 pub fn with_mock_token(
5992 config: RetryConfig,
5993 base_url: Url,
5994 mock_token: String,
5995 ) -> Result<Self, Error> {
5996 let expires_at = Utc::now() + Duration::hours(24); // Mock token valid for 24 hours
5997 let token_data = TokenData::new(mock_token, expires_at);
5998
5999 let manager = Self {
6000 token_data: Arc::new(Mutex::new(Some(token_data))),
6001 retry_client: RetryClient::new(config),
6002 base_url,
6003 token_operation_semaphore: Arc::new(Semaphore::new(1)),
6004 };
6005
6006 Ok(manager)
6007 }
6008
6009 /// Gets a valid authentication token with proactive refresh logic
6010 ///
6011 /// This method implements thread-safe token management logic:
6012 /// 1. Check if a valid token exists and is not expiring soon (within 5 minutes)
6013 /// 2. If token needs refresh/obtain, acquire semaphore to prevent concurrent operations
6014 /// 3. Double-check token state after acquiring semaphore (another thread may have updated it)
6015 /// 4. Perform atomic token update operations
6016 /// 5. Return the valid token
6017 ///
6018 /// # Thread Safety
6019 /// This method is fully thread-safe and prevents race conditions by:
6020 /// - Using a semaphore to ensure only one token operation at a time
6021 /// - Double-checking token state after acquiring the semaphore
6022 /// - Performing atomic token updates within the critical section
6023 ///
6024 /// # Errors
6025 /// Returns a `TokenError` if token acquisition or refresh fails after all retries
6026 pub async fn get_token(&self) -> Result<String, Error> {
6027 // Fast path: check if we have a valid token without acquiring semaphore
6028 if let Some(token) = self.check_existing_token().await? {
6029 return Ok(token);
6030 }
6031
6032 // Slow path: token needs refresh/obtain, acquire semaphore for thread safety
6033 let _permit = self.acquire_token_semaphore().await?;
6034
6035 // Double-check token state after acquiring semaphore - another thread may have updated it
6036 if let Some(token) = self.check_existing_token().await? {
6037 tracing::debug!("Token was updated by another thread, using existing valid token");
6038 return Ok(token);
6039 }
6040
6041 // At this point, we need to refresh or obtain a new token
6042 self.handle_token_refresh_or_obtain().await
6043 }
6044
6045 /// Checks if we have a valid existing token that doesn't expire soon
6046 async fn check_existing_token(&self) -> Result<Option<String>, Error> {
6047 let token_guard = self.token_data.lock().await;
6048 if let Some(ref token_data) = *token_guard {
6049 if !token_data.expires_soon(Duration::minutes(5)) {
6050 tracing::debug!("Using existing valid token");
6051 let token = token_data.token.expose_secret().clone();
6052 drop(token_guard);
6053 return Ok(Some(token));
6054 }
6055 }
6056 drop(token_guard);
6057 Ok(None)
6058 }
6059
6060 /// Acquires the token operation semaphore for thread-safe operations
6061 async fn acquire_token_semaphore(&self) -> Result<tokio::sync::SemaphorePermit<'_>, Error> {
6062 let permit = self
6063 .token_operation_semaphore
6064 .acquire()
6065 .await
6066 .map_err(|e| {
6067 Error::Token(TokenError::storage(format!(
6068 "Failed to acquire token operation semaphore: {e}"
6069 )))
6070 })?;
6071
6072 tracing::debug!("Acquired token operation semaphore for thread-safe token management");
6073 Ok(permit)
6074 }
6075
6076 /// Handles the token refresh or obtain logic
6077 async fn handle_token_refresh_or_obtain(&self) -> Result<String, Error> {
6078 let needs_refresh = self.determine_token_operation().await;
6079
6080 if needs_refresh {
6081 match self.refresh_token_internal().await {
6082 Ok(token) => {
6083 tracing::info!("Token refreshed successfully");
6084 return Ok(token);
6085 }
6086 Err(e) => {
6087 tracing::warn!("Token refresh failed, falling back to obtain: {e}");
6088 // Fall through to obtain new token
6089 }
6090 }
6091 }
6092
6093 // Either we needed to obtain from the start, or refresh failed
6094 self.obtain_token_internal().await
6095 }
6096
6097 /// Determines whether we need to refresh or obtain a new token
6098 async fn determine_token_operation(&self) -> bool {
6099 let token_guard = self.token_data.lock().await;
6100 token_guard.as_ref().map_or_else(
6101 || {
6102 tracing::info!("No token exists, will obtain new token");
6103 false
6104 },
6105 |token_data| {
6106 if token_data.is_expired() {
6107 tracing::info!("Token is expired, will obtain new token");
6108 false
6109 } else {
6110 tracing::info!("Token expires soon, will attempt refresh");
6111 true
6112 }
6113 },
6114 )
6115 }
6116
6117 /// Obtains a new authentication token using environment credentials with retry logic
6118 ///
6119 /// This method:
6120 /// 1. Reads credentials from environment variables
6121 /// 2. Makes a token request with retry logic
6122 /// 3. Stores the new token with 24-hour expiry
6123 /// 4. Returns the token string
6124 ///
6125 /// # Thread Safety
6126 /// This method acquires the token operation semaphore to ensure thread-safe operation.
6127 /// For internal use within already-synchronized contexts, use `obtain_token_internal()`.
6128 ///
6129 /// # Errors
6130 /// Returns an error if:
6131 /// - Environment variables are missing
6132 /// - All retry attempts fail
6133 /// - Response parsing fails
6134 pub async fn obtain_token(&self) -> Result<String, Error> {
6135 let _permit = self
6136 .token_operation_semaphore
6137 .acquire()
6138 .await
6139 .map_err(|e| {
6140 Error::Token(TokenError::storage(format!(
6141 "Failed to acquire token operation semaphore: {e}"
6142 )))
6143 })?;
6144
6145 self.obtain_token_internal().await
6146 }
6147
6148 /// Internal method to obtain a new authentication token without acquiring semaphore
6149 ///
6150 /// This method should only be called from contexts where the token operation semaphore
6151 /// has already been acquired (e.g., from within `get_token()`).
6152 ///
6153 /// # Errors
6154 /// Returns an error if:
6155 /// - Environment variables are missing
6156 /// - All retry attempts fail
6157 /// - Response parsing fails
6158 async fn obtain_token_internal(&self) -> Result<String, Error> {
6159 tracing::debug!("Obtaining new authentication token");
6160
6161 let request_payload = Self::get_credentials_from_env()?;
6162 let url = self.build_obtain_token_url();
6163 let response = self.execute_token_request(&url, &request_payload).await?;
6164 let token_response = self.parse_token_response(response).await?;
6165
6166 self.store_token_data(&token_response.token).await;
6167
6168 tracing::info!("New authentication token obtained successfully");
6169 Ok(token_response.token)
6170 }
6171
6172 /// Gets credentials from environment variables
6173 fn get_credentials_from_env() -> Result<TokenRequest, Error> {
6174 let username = env::var("AMP_USERNAME")
6175 .map_err(|_| Error::MissingEnvVar("AMP_USERNAME".to_string()))?;
6176 let password = env::var("AMP_PASSWORD")
6177 .map_err(|_| Error::MissingEnvVar("AMP_PASSWORD".to_string()))?;
6178
6179 Ok(TokenRequest { username, password })
6180 }
6181
6182 /// Builds the URL for token obtain endpoint
6183 fn build_obtain_token_url(&self) -> Url {
6184 let mut url = self.base_url.clone();
6185 url.path_segments_mut()
6186 .unwrap()
6187 .push("user")
6188 .push("obtain_token");
6189 url
6190 }
6191
6192 /// Executes the token request with retry logic
6193 async fn execute_token_request(
6194 &self,
6195 url: &Url,
6196 request_payload: &TokenRequest,
6197 ) -> Result<reqwest::Response, Error> {
6198 let response = self
6199 .retry_client
6200 .execute_with_retry(|| {
6201 self.retry_client
6202 .client()
6203 .post(url.clone())
6204 .json(request_payload)
6205 })
6206 .await
6207 .map_err(Error::Token)?;
6208
6209 if !response.status().is_success() {
6210 let status = response.status();
6211 let error_text = response
6212 .text()
6213 .await
6214 .unwrap_or_else(|_| "Unknown error".to_string());
6215 return Err(Error::TokenRequestFailed { status, error_text });
6216 }
6217
6218 Ok(response)
6219 }
6220
6221 /// Parses the token response from the API
6222 async fn parse_token_response(
6223 &self,
6224 response: reqwest::Response,
6225 ) -> Result<TokenResponse, Error> {
6226 response
6227 .json()
6228 .await
6229 .map_err(|e| Error::ResponseParsingFailed(e.to_string()))
6230 }
6231
6232 /// Stores the token data with 24-hour expiry and optional disk persistence
6233 async fn store_token_data(&self, token: &str) {
6234 let expires_at = Utc::now() + Duration::days(1);
6235 let token_data = TokenData::new(token.to_string(), expires_at);
6236
6237 // Atomic token update - hold the lock for the minimal time needed
6238 *self.token_data.lock().await = Some(token_data.clone());
6239 tracing::debug!("Token data updated atomically in storage");
6240
6241 // Save to disk if persistence is enabled
6242 if Self::should_persist_tokens() {
6243 if let Err(e) = self.save_token_to_disk(&token_data).await {
6244 tracing::warn!("Failed to save token to disk: {e}");
6245 }
6246 }
6247 }
6248
6249 /// Refreshes the current authentication token with fallback to obtain on failure
6250 ///
6251 /// This method:
6252 /// 1. Uses the existing token to request a refresh
6253 /// 2. Updates the stored token data on success
6254 /// 3. Falls back to obtaining a new token if refresh fails
6255 ///
6256 /// # Thread Safety
6257 /// This method acquires the token operation semaphore to ensure thread-safe operation.
6258 /// For internal use within already-synchronized contexts, use `refresh_token_internal()`.
6259 ///
6260 /// # Errors
6261 /// Returns an error if both refresh and obtain operations fail
6262 pub async fn refresh_token(&self) -> Result<String, Error> {
6263 let _permit = self
6264 .token_operation_semaphore
6265 .acquire()
6266 .await
6267 .map_err(|e| {
6268 Error::Token(TokenError::storage(format!(
6269 "Failed to acquire token operation semaphore: {e}"
6270 )))
6271 })?;
6272
6273 self.refresh_token_internal().await
6274 }
6275
6276 /// Internal method to refresh the current authentication token without acquiring semaphore
6277 ///
6278 /// This method should only be called from contexts where the token operation semaphore
6279 /// has already been acquired (e.g., from within `get_token()`).
6280 ///
6281 /// # Errors
6282 /// Returns an error if both refresh and obtain operations fail
6283 #[allow(clippy::cognitive_complexity)]
6284 async fn refresh_token_internal(&self) -> Result<String, Error> {
6285 tracing::debug!("Refreshing authentication token");
6286
6287 let Some(current_token) = self.get_current_token_for_refresh().await else {
6288 tracing::warn!("No token available for refresh, obtaining new token");
6289 return self.obtain_token_internal().await;
6290 };
6291
6292 let url = self.build_refresh_token_url();
6293 let response = self.execute_refresh_request(&url, ¤t_token).await;
6294
6295 match response {
6296 Ok(resp) => self.handle_refresh_response(resp).await,
6297 Err(e) => {
6298 tracing::warn!("Token refresh request failed: {e}, falling back to obtain");
6299 self.obtain_token_internal().await
6300 }
6301 }
6302 }
6303
6304 /// Gets the current token for refresh operations
6305 async fn get_current_token_for_refresh(&self) -> Option<String> {
6306 let token_guard = self.token_data.lock().await;
6307 token_guard
6308 .as_ref()
6309 .map(|token_data| token_data.token.expose_secret().clone())
6310 }
6311
6312 /// Builds the URL for token refresh endpoint
6313 fn build_refresh_token_url(&self) -> Url {
6314 let mut url = self.base_url.clone();
6315 url.path_segments_mut()
6316 .unwrap()
6317 .push("user")
6318 .push("refresh_token");
6319 url
6320 }
6321
6322 /// Executes the refresh request with retry logic
6323 async fn execute_refresh_request(
6324 &self,
6325 url: &Url,
6326 current_token: &str,
6327 ) -> Result<reqwest::Response, TokenError> {
6328 self.retry_client
6329 .execute_with_retry(|| {
6330 self.retry_client
6331 .client()
6332 .post(url.clone())
6333 .header(AUTHORIZATION, format!("token {current_token}"))
6334 })
6335 .await
6336 }
6337
6338 /// Handles the refresh response, either storing the new token or falling back to obtain
6339 async fn handle_refresh_response(&self, resp: reqwest::Response) -> Result<String, Error> {
6340 if !resp.status().is_success() {
6341 let status = resp.status();
6342 let error_text = resp
6343 .text()
6344 .await
6345 .unwrap_or_else(|_| "Unknown error".to_string());
6346
6347 tracing::warn!("Token refresh failed with status {status}: {error_text}");
6348 return self.obtain_token_internal().await;
6349 }
6350
6351 let token_response: TokenResponse = resp
6352 .json()
6353 .await
6354 .map_err(|e| Error::ResponseParsingFailed(e.to_string()))?;
6355
6356 self.store_token_data(&token_response.token).await;
6357 tracing::info!("Authentication token refreshed successfully");
6358 Ok(token_response.token)
6359 }
6360
6361 /// Gets current token information for debugging and monitoring
6362 ///
6363 /// Returns detailed information about the current token including:
6364 /// - Expiry time and remaining duration
6365 /// - Token age since acquisition
6366 /// - Expiry status flags
6367 ///
6368 /// # Returns
6369 /// `Some(TokenInfo)` if a token exists, `None` if no token is stored
6370 ///
6371 /// # Errors
6372 /// Returns an error if token information retrieval fails
6373 pub async fn get_token_info(&self) -> Result<Option<TokenInfo>, Error> {
6374 tracing::debug!("Retrieving token information for debugging");
6375
6376 let token_info = self.token_data.lock().await.as_ref().map(TokenInfo::from);
6377
6378 match &token_info {
6379 Some(info) => {
6380 tracing::debug!(
6381 "Token info retrieved - expires_at: {}, age: {:?}, expires_in: {:?}, is_expired: {}, expires_soon: {}",
6382 info.expires_at,
6383 info.age,
6384 info.expires_in,
6385 info.is_expired,
6386 info.expires_soon
6387 );
6388 }
6389 None => {
6390 tracing::debug!("No token information available - no token stored");
6391 }
6392 }
6393
6394 Ok(token_info)
6395 }
6396
6397 /// Clears the stored token (useful for testing scenarios)
6398 ///
6399 /// This method removes the current token from storage, forcing the next
6400 /// `get_token()` call to obtain a fresh token.
6401 ///
6402 /// # Errors
6403 /// Returns an error if token clearing fails
6404 pub async fn clear_token(&self) -> Result<(), Error> {
6405 tracing::debug!("Clearing stored token from memory and disk");
6406
6407 let had_token = self.clear_token_from_memory().await;
6408 self.clear_token_from_disk_if_enabled().await;
6409 Self::log_token_clear_result(had_token);
6410
6411 Ok(())
6412 }
6413
6414 /// Clears the token from memory and returns whether a token was present
6415 async fn clear_token_from_memory(&self) -> bool {
6416 let mut token_guard = self.token_data.lock().await;
6417 let had_token = token_guard.is_some();
6418 *token_guard = None;
6419 drop(token_guard);
6420 had_token
6421 }
6422
6423 /// Clears the token from disk if persistence is enabled
6424 async fn clear_token_from_disk_if_enabled(&self) {
6425 if Self::should_persist_tokens() {
6426 if let Err(e) = self.remove_token_from_disk().await {
6427 tracing::warn!("Failed to remove token from disk: {e}");
6428 }
6429 }
6430 }
6431
6432 /// Logs the result of the token clearing operation
6433 fn log_token_clear_result(had_token: bool) {
6434 if had_token {
6435 tracing::info!("Token successfully cleared from memory and disk - next get_token() will obtain fresh token");
6436 } else {
6437 tracing::debug!("No token was stored to clear");
6438 }
6439 }
6440
6441 /// Forces a token refresh regardless of current token status
6442 ///
6443 /// This method bypasses the normal proactive refresh logic and immediately
6444 /// attempts to refresh the current token. If no token exists or refresh fails,
6445 /// it falls back to obtaining a new token.
6446 ///
6447 /// # Thread Safety
6448 /// This method is fully thread-safe and uses the same semaphore-based synchronization
6449 /// as other token operations to prevent race conditions.
6450 ///
6451 /// # Errors
6452 /// Returns an error if both refresh and obtain operations fail
6453 pub async fn force_refresh(&self) -> Result<String, Error> {
6454 tracing::info!("Forcing token refresh - bypassing normal proactive refresh logic");
6455
6456 let _permit = self.acquire_token_semaphore().await?;
6457 self.log_token_status_for_refresh().await;
6458 self.execute_forced_refresh().await
6459 }
6460
6461 /// Logs the current token status for forced refresh operation
6462 async fn log_token_status_for_refresh(&self) {
6463 let has_token = {
6464 let token_guard = self.token_data.lock().await;
6465 token_guard.is_some()
6466 };
6467
6468 if has_token {
6469 tracing::debug!("Existing token found, attempting forced refresh");
6470 } else {
6471 tracing::debug!("No existing token found, will obtain new token");
6472 }
6473 }
6474
6475 /// Executes the forced refresh operation
6476 async fn execute_forced_refresh(&self) -> Result<String, Error> {
6477 match self.refresh_token_internal().await {
6478 Ok(token) => {
6479 tracing::info!("Forced token refresh completed successfully");
6480 Ok(token)
6481 }
6482 Err(e) => {
6483 tracing::error!("Forced token refresh failed: {e}");
6484 Err(e)
6485 }
6486 }
6487 }
6488
6489 /// Determines if token persistence is enabled based on environment variables
6490 ///
6491 /// Token persistence is enabled when:
6492 /// - `AMP_TESTS=live` (for live API testing)
6493 /// - `AMP_TOKEN_PERSISTENCE=true` is set
6494 /// - NOT in mock test environments (to prevent test pollution)
6495 fn should_persist_tokens() -> bool {
6496 // Use the new environment detection logic
6497 let environment = TokenEnvironment::detect();
6498
6499 // Never persist tokens in mock environments to prevent test pollution
6500 if environment.is_mock() {
6501 tracing::debug!("Token persistence disabled - mock environment detected");
6502 return false;
6503 }
6504
6505 // Check if explicitly enabled
6506 if env::var("AMP_TOKEN_PERSISTENCE").unwrap_or_default() == "true" {
6507 tracing::debug!("Token persistence enabled - AMP_TOKEN_PERSISTENCE=true");
6508 return true;
6509 }
6510
6511 // Use environment-based persistence setting
6512 let should_persist = environment.should_persist_tokens();
6513 tracing::debug!(
6514 "Token persistence setting from environment: {}",
6515 should_persist
6516 );
6517 should_persist
6518 }
6519
6520 /// Loads token data from disk if it exists and is valid
6521 async fn load_token_from_disk(&self) -> Result<Option<TokenData>, Error> {
6522 let token_file = "token.json";
6523
6524 if !self.token_file_exists(token_file).await {
6525 return Ok(None);
6526 }
6527
6528 let content = self.read_token_file(token_file).await?;
6529 self.parse_and_validate_token(token_file, &content).await
6530 }
6531
6532 /// Checks if the token file exists on disk
6533 async fn token_file_exists(&self, token_file: &str) -> bool {
6534 tokio::fs::try_exists(token_file).await.map_or_else(
6535 |_| {
6536 tracing::debug!("Error checking token file existence: {}", token_file);
6537 false
6538 },
6539 |exists| {
6540 if !exists {
6541 tracing::debug!("Token file does not exist: {}", token_file);
6542 }
6543 exists
6544 },
6545 )
6546 }
6547
6548 /// Reads the token file content from disk
6549 async fn read_token_file(&self, token_file: &str) -> Result<String, Error> {
6550 use tokio::fs;
6551
6552 match fs::read_to_string(token_file).await {
6553 Ok(content) => Ok(content),
6554 Err(e) => {
6555 tracing::warn!("Failed to read token file: {e}");
6556 Err(Error::Token(TokenError::storage(format!(
6557 "Failed to read token file: {e}"
6558 ))))
6559 }
6560 }
6561 }
6562
6563 /// Parses token content and validates expiration
6564 async fn parse_and_validate_token(
6565 &self,
6566 token_file: &str,
6567 content: &str,
6568 ) -> Result<Option<TokenData>, Error> {
6569 match serde_json::from_str::<TokenData>(content) {
6570 Ok(token_data) => self.handle_parsed_token(token_file, token_data).await,
6571 Err(e) => self.handle_parse_error(token_file, e).await,
6572 }
6573 }
6574
6575 /// Handles successfully parsed token data, checking expiration
6576 async fn handle_parsed_token(
6577 &self,
6578 token_file: &str,
6579 token_data: TokenData,
6580 ) -> Result<Option<TokenData>, Error> {
6581 if token_data.is_expired() {
6582 tracing::info!("Token loaded from disk is expired, removing file");
6583 let _ = tokio::fs::remove_file(token_file).await;
6584 Ok(None)
6585 } else {
6586 tracing::info!("Valid token loaded from disk");
6587 Ok(Some(token_data))
6588 }
6589 }
6590
6591 /// Handles token parsing errors by cleaning up the invalid file
6592 async fn handle_parse_error(
6593 &self,
6594 token_file: &str,
6595 e: serde_json::Error,
6596 ) -> Result<Option<TokenData>, Error> {
6597 tracing::warn!("Failed to parse token file, removing: {e}");
6598 let _ = tokio::fs::remove_file(token_file).await;
6599 Err(Error::Token(TokenError::serialization(format!(
6600 "Failed to parse token file: {e}"
6601 ))))
6602 }
6603
6604 /// Saves token data to disk
6605 async fn save_token_to_disk(&self, token_data: &TokenData) -> Result<(), Error> {
6606 use tokio::fs;
6607
6608 let token_file = "token.json";
6609
6610 match serde_json::to_string_pretty(token_data) {
6611 Ok(json) => match fs::write(token_file, json).await {
6612 Ok(()) => {
6613 tracing::debug!("Token saved to disk: {}", token_file);
6614 Ok(())
6615 }
6616 Err(e) => {
6617 tracing::error!("Failed to write token file: {e}");
6618 Err(Error::Token(TokenError::storage(format!(
6619 "Failed to write token file: {e}"
6620 ))))
6621 }
6622 },
6623 Err(e) => {
6624 tracing::error!("Failed to serialize token data: {e}");
6625 Err(Error::Token(TokenError::serialization(format!(
6626 "Failed to serialize token data: {e}"
6627 ))))
6628 }
6629 }
6630 }
6631
6632 /// Removes the token file from disk
6633 async fn remove_token_from_disk(&self) -> Result<(), Error> {
6634 use tokio::fs;
6635
6636 let token_file = "token.json";
6637
6638 match fs::remove_file(token_file).await {
6639 Ok(()) => {
6640 tracing::debug!("Token file removed from disk: {}", token_file);
6641 Ok(())
6642 }
6643 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
6644 tracing::debug!("Token file does not exist, nothing to remove");
6645 Ok(())
6646 }
6647 Err(e) => {
6648 tracing::warn!("Failed to remove token file: {e}");
6649 Err(Error::Token(TokenError::storage(format!(
6650 "Failed to remove token file: {e}"
6651 ))))
6652 }
6653 }
6654 }
6655
6656 /// Forces cleanup of token persistence files (useful for testing)
6657 /// This method removes token files regardless of persistence settings
6658 ///
6659 /// # Errors
6660 /// Returns an error if:
6661 /// - File system permissions prevent deletion of the token file
6662 /// - I/O errors occur during file deletion operations
6663 /// - The token file is locked by another process
6664 pub async fn force_cleanup_token_files() -> Result<(), Error> {
6665 use tokio::fs;
6666
6667 let token_file = "token.json";
6668
6669 match fs::remove_file(token_file).await {
6670 Ok(()) => {
6671 tracing::debug!("Token file forcefully removed: {}", token_file);
6672 Ok(())
6673 }
6674 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
6675 tracing::debug!("No token file to clean up");
6676 Ok(())
6677 }
6678 Err(e) => {
6679 tracing::warn!("Failed to force cleanup token file: {e}");
6680 Err(Error::Token(TokenError::storage(format!(
6681 "Failed to force cleanup token file: {e}"
6682 ))))
6683 }
6684 }
6685 }
6686
6687 /// Resets the global `TokenManager` singleton (useful for testing)
6688 ///
6689 /// This method clears the global singleton instance, forcing the next
6690 /// call to `get_global_instance()` to create a fresh `TokenManager`.
6691 /// Primarily intended for test scenarios where a clean state is needed.
6692 ///
6693 /// # Errors
6694 /// Returns an error if:
6695 /// - Token clearing operations fail during the reset process
6696 /// - File system errors occur when clearing persistent token data
6697 /// - The global instance is in an invalid state that prevents cleanup
6698 pub async fn reset_global_instance() -> Result<(), Error> {
6699 // Clear any existing token from the current global instance
6700 if let Some(manager) = GLOBAL_TOKEN_MANAGER.get() {
6701 let _ = manager.clear_token().await;
6702 }
6703
6704 // Reset the OnceCell to allow a new instance to be created
6705 // Note: OnceCell doesn't have a reset method, so we can't actually reset it
6706 // The best we can do is clear the token from the existing instance
6707 tracing::debug!("Global TokenManager instance token cleared for testing");
6708 Ok(())
6709 }
6710}
6711
6712#[derive(Debug)]
6713pub struct ApiClient {
6714 client: Client,
6715 base_url: Url,
6716 token_strategy: Box<dyn TokenStrategy>,
6717}
6718
6719#[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
6720impl ApiClient {
6721 /// Creates a new API client with the base URL from environment variables.
6722 ///
6723 /// Automatically selects the appropriate token strategy based on environment detection:
6724 /// - Mock strategy for mock environments (no persistence, isolated tokens)
6725 /// - Live strategy for live environments (full token management with persistence)
6726 ///
6727 /// # Errors
6728 ///
6729 /// Returns an error if:
6730 /// - The `AMP_API_BASE_URL` environment variable contains an invalid URL
6731 /// - Token strategy initialization fails
6732 ///
6733 /// # Examples
6734 /// ```no_run
6735 /// # use amp_rs::ApiClient;
6736 /// # #[tokio::main]
6737 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
6738 /// // Create a new client - automatically detects environment
6739 /// let client = ApiClient::new().await?;
6740 ///
6741 /// // Client is ready to use
6742 /// let assets = client.get_assets().await?;
6743 /// println!("Found {} assets", assets.len());
6744 /// # Ok(())
6745 /// # }
6746 /// ```
6747 pub async fn new() -> Result<Self, Error> {
6748 let base_url = get_amp_api_base_url()?;
6749 let client = Client::new();
6750
6751 // Automatic strategy selection based on environment
6752 let token_strategy = TokenEnvironment::create_auto_strategy(None).await?;
6753
6754 tracing::info!(
6755 "Created ApiClient with {} strategy for base URL: {}",
6756 token_strategy.strategy_type(),
6757 base_url
6758 );
6759
6760 Ok(Self {
6761 client,
6762 base_url,
6763 token_strategy,
6764 })
6765 }
6766
6767 /// Creates a new API client with the specified base URL.
6768 ///
6769 /// Automatically selects the appropriate token strategy based on environment detection.
6770 ///
6771 /// # Errors
6772 ///
6773 /// Returns an error if token strategy initialization fails.
6774 ///
6775 /// # Examples
6776 /// ```no_run
6777 /// # use amp_rs::ApiClient;
6778 /// # use reqwest::Url;
6779 /// # #[tokio::main]
6780 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
6781 /// let base_url = Url::parse("https://amp-test.blockstream.com/api")?;
6782 /// let client = ApiClient::with_base_url(base_url).await?;
6783 ///
6784 /// // Client is ready to use with the specified URL
6785 /// let assets = client.get_assets().await?;
6786 /// # Ok(())
6787 /// # }
6788 /// ```
6789 pub async fn with_base_url(base_url: Url) -> Result<Self, Error> {
6790 let client = Client::new();
6791
6792 // Automatic strategy selection based on environment
6793 let token_strategy = TokenEnvironment::create_auto_strategy(None).await?;
6794
6795 tracing::info!(
6796 "Created ApiClient with {} strategy for base URL: {}",
6797 token_strategy.strategy_type(),
6798 base_url
6799 );
6800
6801 Ok(Self {
6802 client,
6803 base_url,
6804 token_strategy,
6805 })
6806 }
6807
6808 /// Creates a new API client with a custom token strategy (useful for testing).
6809 ///
6810 /// # Errors
6811 ///
6812 /// Returns an error if the base URL cannot be obtained from environment variables.
6813 pub fn with_token_strategy(token_strategy: Box<dyn TokenStrategy>) -> Result<Self, Error> {
6814 let base_url = get_amp_api_base_url()?;
6815
6816 tracing::info!(
6817 "Created ApiClient with explicit {} strategy for base URL: {}",
6818 token_strategy.strategy_type(),
6819 base_url
6820 );
6821
6822 Ok(Self {
6823 client: Client::new(),
6824 base_url,
6825 token_strategy,
6826 })
6827 }
6828
6829 /// Creates a new API client with a custom token manager (useful for testing).
6830 ///
6831 /// # Errors
6832 ///
6833 /// Returns an error if the base URL cannot be obtained from environment variables.
6834 pub fn with_token_manager(token_manager: Arc<TokenManager>) -> Result<Self, Error> {
6835 let base_url = get_amp_api_base_url()?;
6836 let token_strategy: Box<dyn TokenStrategy> =
6837 Box::new(LiveTokenStrategy::with_token_manager(token_manager));
6838
6839 tracing::info!(
6840 "Created ApiClient with custom token manager for base URL: {}",
6841 base_url
6842 );
6843
6844 Ok(Self {
6845 client: Client::new(),
6846 base_url,
6847 token_strategy,
6848 })
6849 }
6850
6851 /// Creates a new API client for testing with a mock token strategy that always returns a fixed token.
6852 /// This bypasses all token acquisition and management logic and uses complete isolation.
6853 ///
6854 /// # Errors
6855 ///
6856 /// This method is infallible but returns Result for API consistency.
6857 ///
6858 /// # Examples
6859 /// ```
6860 /// # use amp_rs::ApiClient;
6861 /// # use reqwest::Url;
6862 /// # #[tokio::main]
6863 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
6864 /// let base_url = Url::parse("http://localhost:8080/api")?;
6865 /// let client = ApiClient::with_mock_token(base_url, "test_token".to_string())?;
6866 ///
6867 /// // Client will always use "test_token" for authentication
6868 /// let token = client.get_token().await?;
6869 /// assert_eq!(token, "test_token");
6870 /// # Ok(())
6871 /// # }
6872 /// ```
6873 pub fn with_mock_token(base_url: Url, mock_token: String) -> Result<Self, Error> {
6874 let client = Client::new();
6875 let token_strategy: Box<dyn TokenStrategy> = Box::new(MockTokenStrategy::new(mock_token));
6876
6877 tracing::info!(
6878 "Created ApiClient with explicit mock token strategy for base URL: {}",
6879 base_url
6880 );
6881
6882 Ok(Self {
6883 client,
6884 base_url,
6885 token_strategy,
6886 })
6887 }
6888
6889 /// Obtains a new authentication token from the AMP API.
6890 ///
6891 /// **Note**: This method is deprecated in favor of the automatic token management
6892 /// provided by `get_token()`. The `TokenManager` handles token acquisition internally
6893 /// with enhanced retry logic and error handling.
6894 ///
6895 /// # Errors
6896 ///
6897 /// Returns an error if:
6898 /// - The `AMP_USERNAME` or `AMP_PASSWORD` environment variables are not set
6899 /// - The HTTP request fails
6900 /// - The token request is rejected by the server
6901 /// - The response cannot be parsed
6902 #[deprecated(note = "Use get_token() instead - it provides automatic token management")]
6903 pub async fn obtain_amp_token(&self) -> Result<String, Error> {
6904 // Delegate to get_token for backward compatibility
6905 self.get_token().await
6906 }
6907
6908 /// Gets current token information for debugging and monitoring.
6909 ///
6910 /// Returns detailed information about the current token including:
6911 /// - Expiry time and remaining duration
6912 /// - Token age since acquisition
6913 /// - Expiry status flags
6914 ///
6915 /// Note: Mock strategies may return limited or no token information.
6916 ///
6917 /// # Returns
6918 /// `Some(TokenInfo)` if a token exists, `None` if no token is stored or strategy doesn't support info
6919 ///
6920 /// # Errors
6921 /// Returns an error if token information retrieval fails
6922 ///
6923 /// # Examples
6924 /// ```no_run
6925 /// # use amp_rs::ApiClient;
6926 /// # #[tokio::main]
6927 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
6928 /// let client = ApiClient::new().await?;
6929 ///
6930 /// if let Some(token_info) = client.get_token_info().await? {
6931 /// println!("Token expires at: {}", token_info.expires_at);
6932 /// println!("Token is expired: {}", token_info.is_expired);
6933 /// } else {
6934 /// println!("No token stored or mock strategy in use");
6935 /// }
6936 /// # Ok(())
6937 /// # }
6938 /// ```
6939 pub async fn get_token_info(&self) -> Result<Option<TokenInfo>, Error> {
6940 // Only live strategies support detailed token information
6941 if let Some(live_strategy) = self
6942 .token_strategy
6943 .as_any()
6944 .downcast_ref::<LiveTokenStrategy>()
6945 {
6946 live_strategy.get_token_info().await
6947 } else {
6948 // Mock strategies don't provide detailed token information
6949 tracing::debug!(
6950 "Token info not available for {} strategy",
6951 self.token_strategy.strategy_type()
6952 );
6953 Ok(None)
6954 }
6955 }
6956
6957 /// Clears the stored token (useful for testing scenarios).
6958 ///
6959 /// This method removes the current token from storage, forcing the next
6960 /// `get_token()` call to obtain a fresh token.
6961 ///
6962 /// # Errors
6963 /// Returns an error if token clearing fails
6964 ///
6965 /// # Examples
6966 /// ```no_run
6967 /// # use amp_rs::ApiClient;
6968 /// # #[tokio::main]
6969 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
6970 /// let client = ApiClient::new().await?;
6971 ///
6972 /// // Clear any existing token
6973 /// client.clear_token().await?;
6974 ///
6975 /// // Next get_token() call will obtain a fresh token
6976 /// let token = client.get_token().await?;
6977 /// # Ok(())
6978 /// # }
6979 /// ```
6980 pub async fn clear_token(&self) -> Result<(), Error> {
6981 self.token_strategy.clear_token().await
6982 }
6983
6984 /// Forces a token refresh regardless of current token status.
6985 ///
6986 /// This method bypasses the normal proactive refresh logic and immediately
6987 /// attempts to refresh the current token. If no token exists or refresh fails,
6988 /// it falls back to obtaining a new token.
6989 ///
6990 /// # Errors
6991 /// Returns an error if both refresh and obtain operations fail
6992 pub async fn force_refresh(&self) -> Result<String, Error> {
6993 // Clear current token and get a fresh one
6994 self.token_strategy.clear_token().await?;
6995 self.token_strategy.get_token().await
6996 }
6997
6998 /// Resets the global `TokenManager` singleton (useful for testing).
6999 ///
7000 /// This method clears the token from the global `TokenManager` instance.
7001 /// Primarily intended for test scenarios where a clean token state is needed.
7002 ///
7003 /// # Errors
7004 /// Returns an error if the reset operation fails
7005 pub async fn reset_global_token_manager() -> Result<(), Error> {
7006 TokenManager::reset_global_instance().await
7007 }
7008
7009 /// Gets a valid authentication token with automatic token management.
7010 ///
7011 /// This method uses the integrated `TokenManager` to handle:
7012 /// - Proactive token refresh (5 minutes before expiry)
7013 /// - Automatic fallback from refresh to obtain on failure
7014 /// - Retry logic with exponential backoff
7015 /// - Thread-safe token storage
7016 ///
7017 /// # Errors
7018 ///
7019 /// Returns an error if token acquisition or refresh fails after all retries.
7020 ///
7021 /// # Examples
7022 /// ```no_run
7023 /// # use amp_rs::ApiClient;
7024 /// # #[tokio::main]
7025 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7026 /// let client = ApiClient::new().await?;
7027 ///
7028 /// // Get a valid token - automatically handles refresh if needed
7029 /// let token = client.get_token().await?;
7030 /// println!("Got token: {}", &token[..10]); // Print first 10 chars
7031 /// # Ok(())
7032 /// # }
7033 /// ```
7034 pub async fn get_token(&self) -> Result<String, Error> {
7035 self.token_strategy.get_token().await
7036 }
7037
7038 /// Returns the type of token strategy currently in use
7039 ///
7040 /// This is useful for debugging and testing to verify the correct strategy is selected.
7041 ///
7042 /// # Returns
7043 /// A string indicating the strategy type: "mock" or "live"
7044 #[must_use]
7045 pub fn get_strategy_type(&self) -> &'static str {
7046 self.token_strategy.strategy_type()
7047 }
7048
7049 /// Returns whether the current strategy persists tokens
7050 ///
7051 /// This is useful for understanding the token management behavior.
7052 ///
7053 /// # Returns
7054 /// `true` if tokens are persisted to disk, `false` for in-memory only
7055 #[must_use]
7056 pub fn should_persist_tokens(&self) -> bool {
7057 self.token_strategy.should_persist()
7058 }
7059
7060 /// Force cleanup of token files (for test cleanup)
7061 ///
7062 /// This is a static method that can be used to cleanup token files
7063 /// without needing an `ApiClient` instance. Useful for test teardown.
7064 ///
7065 /// # Errors
7066 /// Returns an error if token file cleanup fails
7067 pub async fn force_cleanup_token_files() -> Result<(), Error> {
7068 // Only cleanup if we're not in a live test environment
7069 let environment = TokenEnvironment::detect();
7070 if !environment.is_live() || environment.is_mock() {
7071 TokenManager::force_cleanup_token_files().await?;
7072 tracing::debug!("Token files cleaned up for non-live environment");
7073 } else {
7074 tracing::debug!("Skipping token file cleanup in live environment");
7075 }
7076 Ok(())
7077 }
7078
7079 async fn request_raw(
7080 &self,
7081 method: Method,
7082 path: &[&str],
7083 body: Option<impl serde::Serialize>,
7084 ) -> Result<reqwest::Response, Error> {
7085 let debug_logging = std::env::var("AMP_DEBUG").is_ok();
7086
7087 if debug_logging {
7088 eprintln!("🌐 HTTP Request: {} /{}", method, path.join("/"));
7089 }
7090
7091 let token = self.get_token().await?;
7092 let mut url = self.base_url.clone();
7093 url.path_segments_mut().unwrap().extend(path);
7094
7095 if debug_logging {
7096 eprintln!("🔗 Full URL: {url}");
7097 }
7098
7099 // Retry logic for network issues
7100 let max_retries = 3;
7101 let mut last_error = None;
7102
7103 for attempt in 1..=max_retries {
7104 if debug_logging && attempt > 1 {
7105 eprintln!("🔄 Retry attempt {attempt} of {max_retries}");
7106 }
7107
7108 let mut request_builder = self
7109 .client
7110 .request(method.clone(), url.clone())
7111 .header(AUTHORIZATION, format!("token {token}"))
7112 .timeout(std::time::Duration::from_secs(60)); // Increase timeout to 60 seconds
7113
7114 if let Some(ref body) = body {
7115 if debug_logging && attempt == 1 {
7116 if let Ok(json_body) = serde_json::to_string_pretty(&body) {
7117 eprintln!(
7118 "📤 Request body ({} bytes):\n{}",
7119 json_body.len(),
7120 json_body
7121 );
7122 } else {
7123 eprintln!("📤 Request body: [serialization failed]");
7124 }
7125 }
7126 request_builder = request_builder.json(&body);
7127 } else if debug_logging && attempt == 1 {
7128 eprintln!("📤 Request body: [empty]");
7129 }
7130
7131 if debug_logging {
7132 eprintln!("🚀 Sending HTTP request (attempt {attempt})...");
7133 }
7134
7135 match request_builder.send().await {
7136 Ok(response) => {
7137 let status = response.status();
7138
7139 if debug_logging {
7140 eprintln!("📥 Response status: {status}");
7141 }
7142
7143 if !status.is_success() {
7144 let error_text = response
7145 .text()
7146 .await
7147 .unwrap_or_else(|_| "Unknown error".to_string());
7148
7149 if debug_logging {
7150 eprintln!("❌ Error response body: {error_text}");
7151 }
7152
7153 return Err(Error::RequestFailed(format!(
7154 "Request to {path:?} failed with status {status}: {error_text}"
7155 )));
7156 }
7157
7158 if debug_logging {
7159 eprintln!("✅ HTTP request successful");
7160 }
7161
7162 return Ok(response);
7163 }
7164 Err(e) => {
7165 if debug_logging {
7166 eprintln!("❌ HTTP request failed (attempt {attempt}): {e:?}");
7167 eprintln!(" Error kind: {:?}", e.is_timeout());
7168 eprintln!(" Is connect error: {}", e.is_connect());
7169 eprintln!(" Is request error: {}", e.is_request());
7170 }
7171
7172 last_error = Some(e);
7173
7174 // Only retry on network/connection errors, not on client errors
7175 if attempt < max_retries {
7176 #[allow(clippy::cast_sign_loss)] // attempt is always positive (1-3)
7177 let delay = std::time::Duration::from_millis((attempt as u64) * 1000);
7178 if debug_logging {
7179 eprintln!("⏳ Waiting {}ms before retry...", delay.as_millis());
7180 }
7181 tokio::time::sleep(delay).await;
7182 }
7183 }
7184 }
7185 }
7186
7187 // If we get here, all retries failed
7188 if debug_logging {
7189 eprintln!("❌ All {max_retries} retry attempts failed");
7190 }
7191
7192 Err(Error::Reqwest(last_error.unwrap()))
7193 }
7194
7195 async fn request_json<T: DeserializeOwned>(
7196 &self,
7197 method: Method,
7198 path: &[&str],
7199 body: Option<impl serde::Serialize>,
7200 ) -> Result<T, Error> {
7201 let response = self.request_raw(method, path, body).await?;
7202 response
7203 .json()
7204 .await
7205 .map_err(|e| Error::ResponseParsingFailed(e.to_string()))
7206 }
7207
7208 async fn request_empty(
7209 &self,
7210 method: Method,
7211 path: &[&str],
7212 body: Option<impl serde::Serialize>,
7213 ) -> Result<(), Error> {
7214 self.request_raw(method, path, body).await?;
7215 Ok(())
7216 }
7217
7218 /// Gets the API changelog.
7219 ///
7220 /// # Errors
7221 ///
7222 /// Returns an error if:
7223 /// - Authentication fails
7224 /// - The HTTP request fails
7225 /// - The server returns an error status
7226 /// - The response cannot be parsed as JSON
7227 pub async fn get_changelog(&self) -> Result<serde_json::Value, Error> {
7228 self.request_json(Method::GET, &["changelog"], None::<&()>)
7229 .await
7230 }
7231
7232 /// Changes the user's password.
7233 ///
7234 /// # Errors
7235 ///
7236 /// Returns an error if:
7237 /// - Authentication fails
7238 /// - The HTTP request fails
7239 /// - The server rejects the password change
7240 /// - The response cannot be parsed
7241 pub async fn user_change_password(
7242 &self,
7243 password: Secret<String>,
7244 ) -> Result<ChangePasswordResponse, Error> {
7245 let request = ChangePasswordRequest {
7246 password: Secret::new(Password(password.expose_secret().clone())),
7247 };
7248 self.request_json(Method::POST, &["user", "change_password"], Some(request))
7249 .await
7250 }
7251
7252 /// Gets a list of all assets.
7253 ///
7254 /// # Errors
7255 ///
7256 /// Returns an error if:
7257 /// - Authentication fails
7258 /// - The HTTP request fails
7259 /// - The server returns an error status
7260 /// - The response cannot be parsed
7261 ///
7262 /// # Examples
7263 /// ```no_run
7264 /// # use amp_rs::ApiClient;
7265 /// # #[tokio::main]
7266 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7267 /// let client = ApiClient::new().await?;
7268 ///
7269 /// let assets = client.get_assets().await?;
7270 /// for asset in assets {
7271 /// println!("Asset: {} ({})", asset.name, asset.ticker.unwrap_or_default());
7272 /// }
7273 /// # Ok(())
7274 /// # }
7275 /// ```
7276 pub async fn get_assets(&self) -> Result<Vec<Asset>, Error> {
7277 self.request_json(Method::GET, &["assets"], None::<&()>)
7278 .await
7279 }
7280
7281 /// Gets a specific asset by UUID.
7282 ///
7283 /// # Errors
7284 ///
7285 /// Returns an error if:
7286 /// - Authentication fails
7287 /// - The HTTP request fails
7288 /// - The asset does not exist
7289 /// - The response cannot be parsed
7290 ///
7291 /// # Examples
7292 /// ```no_run
7293 /// # use amp_rs::ApiClient;
7294 /// # #[tokio::main]
7295 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7296 /// let client = ApiClient::new().await?;
7297 ///
7298 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
7299 /// let asset = client.get_asset(asset_uuid).await?;
7300 ///
7301 /// println!("Asset: {} ({})", asset.name, asset.ticker.unwrap_or_default());
7302 /// println!("Registered: {}, Locked: {}", asset.is_registered, asset.is_locked);
7303 /// # Ok(())
7304 /// # }
7305 /// ```
7306 pub async fn get_asset(&self, asset_uuid: &str) -> Result<Asset, Error> {
7307 self.request_json(Method::GET, &["assets", asset_uuid], None::<&()>)
7308 .await
7309 }
7310
7311 /// Issues a new asset.
7312 ///
7313 /// # Errors
7314 ///
7315 /// Returns an error if:
7316 /// - Authentication fails
7317 /// - The HTTP request fails
7318 /// - The issuance request is invalid
7319 /// - The response cannot be parsed
7320 ///
7321 /// # Examples
7322 /// ```no_run
7323 /// # use amp_rs::{ApiClient, model::IssuanceRequest};
7324 /// # #[tokio::main]
7325 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7326 /// let client = ApiClient::new().await?;
7327 ///
7328 /// let issuance_request = IssuanceRequest {
7329 /// name: "My Token".to_string(),
7330 /// amount: 1000000,
7331 /// destination_address: "vjU2i2EM2viGEzSywpStMPkTX9U9QSDsLSN63kJJYVpxKJZuxaph8v5r5Jf11aqnfBVdjSbrvcJ2pw26".to_string(),
7332 /// domain: "example.com".to_string(),
7333 /// ticker: "MYTKN".to_string(),
7334 /// pubkey: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
7335 /// precision: Some(8),
7336 /// is_confidential: Some(true),
7337 /// is_reissuable: Some(false),
7338 /// reissuance_amount: None,
7339 /// reissuance_address: None,
7340 /// transfer_restricted: Some(false),
7341 /// };
7342 ///
7343 /// let response = client.issue_asset(&issuance_request).await?;
7344 /// println!("Issued asset with UUID: {}", response.asset_uuid);
7345 /// # Ok(())
7346 /// # }
7347 /// ```
7348 pub async fn issue_asset(
7349 &self,
7350 issuance_request: &IssuanceRequest,
7351 ) -> Result<IssuanceResponse, Error> {
7352 self.request_json(Method::POST, &["assets", "issue"], Some(issuance_request))
7353 .await
7354 }
7355
7356 /// Edits an existing asset.
7357 ///
7358 /// # Errors
7359 ///
7360 /// Returns an error if:
7361 /// - Authentication fails
7362 /// - The HTTP request fails
7363 /// - The asset does not exist
7364 /// - The edit request is invalid
7365 /// - The response cannot be parsed
7366 pub async fn edit_asset(
7367 &self,
7368 asset_uuid: &str,
7369 edit_asset_request: &EditAssetRequest,
7370 ) -> Result<Asset, Error> {
7371 self.request_json(
7372 Method::PUT,
7373 &["assets", asset_uuid, "edit"],
7374 Some(edit_asset_request),
7375 )
7376 .await
7377 }
7378
7379 /// # Errors
7380 /// Returns an error if:
7381 /// - The asset does not exist or cannot be found
7382 /// - Authentication fails or token is invalid
7383 /// - Network connectivity issues occur
7384 /// - The server returns an error status
7385 pub async fn delete_asset(&self, asset_uuid: &str) -> Result<(), Error> {
7386 self.request_empty(
7387 Method::DELETE,
7388 &["assets", asset_uuid, "delete"],
7389 None::<&()>,
7390 )
7391 .await
7392 }
7393
7394 /// # Errors
7395 /// Returns an error if:
7396 /// - The transaction ID is invalid or not found
7397 /// - Authentication fails or token is invalid
7398 /// - Network connectivity issues occur
7399 /// - The server returns an error status
7400 /// - The response cannot be parsed
7401 pub async fn get_broadcast_status(&self, txid: &str) -> Result<BroadcastResponse, Error> {
7402 self.request_json(Method::GET, &["tx", "broadcast", txid], None::<&()>)
7403 .await
7404 }
7405
7406 /// # Errors
7407 /// Returns an error if:
7408 /// - The transaction hex is invalid or malformed
7409 /// - The transaction is rejected by the network
7410 /// - Authentication fails or token is invalid
7411 /// - Network connectivity issues occur
7412 /// - The server returns an error status
7413 /// - The response cannot be parsed
7414 pub async fn broadcast_transaction(&self, tx_hex: &str) -> Result<BroadcastResponse, Error> {
7415 self.request_json(Method::POST, &["tx", "broadcast"], Some(tx_hex))
7416 .await
7417 }
7418
7419 /// # Errors
7420 /// Returns an error if:
7421 /// - The asset UUID is invalid or not found
7422 /// - The asset is already registered
7423 /// - Authentication fails or token is invalid
7424 /// - Network connectivity issues occur
7425 /// - The server returns an error status
7426 /// - The response cannot be parsed
7427 pub async fn register_asset(&self, asset_uuid: &str) -> Result<Asset, Error> {
7428 self.request_json(
7429 Method::GET,
7430 &["assets", asset_uuid, "register"],
7431 None::<&()>,
7432 )
7433 .await
7434 }
7435
7436 /// # Errors
7437 /// Returns an error if:
7438 /// - The asset UUID is invalid or not found
7439 /// - The user lacks authorization to register the asset
7440 /// - The asset is already registered
7441 /// - Authentication fails or token is invalid
7442 /// - Network connectivity issues occur
7443 /// - The server returns an error status
7444 /// - The response cannot be parsed
7445 pub async fn register_asset_authorized(&self, asset_uuid: &str) -> Result<Asset, Error> {
7446 self.request_json(
7447 Method::GET,
7448 &["assets", asset_uuid, "register-authorized"],
7449 None::<&()>,
7450 )
7451 .await
7452 }
7453
7454 /// # Errors
7455 /// Returns an error if:
7456 /// - The asset UUID is invalid or not found
7457 /// - The asset is already locked
7458 /// - The user lacks permission to lock the asset
7459 /// - Authentication fails or token is invalid
7460 /// - Network connectivity issues occur
7461 /// - The server returns an error status
7462 /// - The response cannot be parsed
7463 pub async fn lock_asset(&self, asset_uuid: &str) -> Result<Asset, Error> {
7464 self.request_json(Method::PUT, &["assets", asset_uuid, "lock"], None::<&()>)
7465 .await
7466 }
7467
7468 /// # Errors
7469 /// Returns an error if:
7470 /// - The asset UUID is invalid or not found
7471 /// - The asset is not currently locked
7472 /// - The user lacks permission to unlock the asset
7473 /// - Authentication fails or token is invalid
7474 /// - Network connectivity issues occur
7475 /// - The server returns an error status
7476 /// - The response cannot be parsed
7477 pub async fn unlock_asset(&self, asset_uuid: &str) -> Result<Asset, Error> {
7478 self.request_json(Method::PUT, &["assets", asset_uuid, "unlock"], None::<&()>)
7479 .await
7480 }
7481
7482 /// # Errors
7483 /// Returns an error if:
7484 /// - The asset UUID is invalid or not found
7485 /// - The activity parameters are invalid
7486 /// - Authentication fails or token is invalid
7487 /// - Network connectivity issues occur
7488 /// - The server returns an error status
7489 /// - The response cannot be parsed
7490 pub async fn get_asset_activities(
7491 &self,
7492 asset_uuid: &str,
7493 params: &AssetActivityParams,
7494 ) -> Result<Vec<Activity>, Error> {
7495 self.request_json(
7496 Method::GET,
7497 &["assets", asset_uuid, "activities"],
7498 Some(params),
7499 )
7500 .await
7501 }
7502
7503 /// # Errors
7504 /// Returns an error if:
7505 /// - The asset UUID is invalid or not found
7506 /// - The specified height is invalid or out of range
7507 /// - Authentication fails or token is invalid
7508 /// - Network connectivity issues occur
7509 /// - The server returns an error status
7510 /// - The response cannot be parsed
7511 pub async fn get_asset_ownerships(
7512 &self,
7513 asset_uuid: &str,
7514 height: Option<i64>,
7515 ) -> Result<Vec<Ownership>, Error> {
7516 let mut path = vec!["assets", asset_uuid, "ownerships"];
7517 let height_str;
7518 if let Some(h) = height {
7519 height_str = h.to_string();
7520 path.push(&height_str);
7521 }
7522 self.request_json(Method::GET, &path, None::<&()>).await
7523 }
7524
7525 /// # Errors
7526 /// Returns an error if:
7527 /// - The asset UUID is invalid or not found
7528 /// - Authentication fails or token is invalid
7529 /// - Network connectivity issues occur
7530 /// - The server returns an error status
7531 /// - The response cannot be parsed
7532 pub async fn get_asset_balance(&self, asset_uuid: &str) -> Result<Balance, Error> {
7533 self.request_json(Method::GET, &["assets", asset_uuid, "balance"], None::<&()>)
7534 .await
7535 }
7536
7537 /// # Errors
7538 /// Returns an error if:
7539 /// - The asset UUID is invalid or not found
7540 /// - Authentication fails or token is invalid
7541 /// - Network connectivity issues occur
7542 /// - The server returns an error status
7543 /// - The response cannot be parsed
7544 pub async fn get_asset_summary(&self, asset_uuid: &str) -> Result<AssetSummary, Error> {
7545 self.request_json(Method::GET, &["assets", asset_uuid, "summary"], None::<&()>)
7546 .await
7547 }
7548
7549 /// # Errors
7550 /// Returns an error if:
7551 /// - The asset UUID is invalid or not found
7552 /// - Authentication fails or token is invalid
7553 /// - Network connectivity issues occur
7554 /// - The server returns an error status
7555 /// - The response cannot be parsed
7556 pub async fn get_asset_utxos(&self, asset_uuid: &str) -> Result<Vec<Utxo>, Error> {
7557 self.request_json(Method::GET, &["assets", asset_uuid, "utxos"], None::<&()>)
7558 .await
7559 }
7560
7561 /// Gets the memo for a specific asset.
7562 ///
7563 /// # Arguments
7564 /// * `asset_uuid` - The UUID of the asset to retrieve the memo for
7565 ///
7566 /// # Returns
7567 /// The memo string associated with the asset
7568 ///
7569 /// # Errors
7570 /// Returns an error if:
7571 /// - Authentication fails
7572 /// - The HTTP request fails
7573 /// - The server returns an error status
7574 /// - The asset does not exist
7575 /// - The response cannot be parsed
7576 pub async fn get_asset_memo(&self, asset_uuid: &str) -> Result<String, Error> {
7577 self.request_json(Method::GET, &["assets", asset_uuid, "memo"], None::<&()>)
7578 .await
7579 }
7580
7581 /// Sets a memo for the specified asset.
7582 ///
7583 /// # Arguments
7584 /// * `asset_uuid` - The UUID of the asset to set the memo for
7585 /// * `memo` - The memo string to associate with the asset
7586 ///
7587 /// # Returns
7588 /// Returns `Ok(())` on success.
7589 ///
7590 /// # Errors
7591 /// Returns an error if:
7592 /// - Authentication fails
7593 /// - The HTTP request fails
7594 /// - The server returns an error status
7595 /// - The asset does not exist
7596 /// - The memo cannot be set due to validation errors
7597 ///
7598 /// # Example
7599 /// ```rust
7600 /// # use amp_rs::ApiClient;
7601 /// # async fn example(client: &ApiClient) -> Result<(), Box<dyn std::error::Error>> {
7602 /// client.set_asset_memo("asset-uuid-123", "This is a memo for the asset").await?;
7603 /// # Ok(())
7604 /// # }
7605 /// ```
7606 pub async fn set_asset_memo(&self, asset_uuid: &str, memo: &str) -> Result<(), Error> {
7607 let token = self.get_token().await?;
7608 let mut url = self.base_url.clone();
7609 url.path_segments_mut()
7610 .unwrap()
7611 .extend(&["assets", asset_uuid, "memo", "set"]);
7612
7613 let response = self
7614 .client
7615 .request(Method::POST, url)
7616 .header(AUTHORIZATION, format!("token {token}"))
7617 .header("content-type", "application/json")
7618 .body(format!("\"{}\"", memo.replace('"', "\\\"")))
7619 .send()
7620 .await?;
7621
7622 if !response.status().is_success() {
7623 let status = response.status();
7624 let error_text = response
7625 .text()
7626 .await
7627 .unwrap_or_else(|_| "Unknown error".to_string());
7628 return Err(Error::RequestFailed(format!(
7629 "Request to [\"assets\", \"{asset_uuid}\", \"memo\", \"set\"] failed with status {status}: {error_text}"
7630 )));
7631 }
7632
7633 Ok(())
7634 }
7635
7636 /// Blacklists specific UTXOs for an asset to prevent them from being used in transactions.
7637 ///
7638 /// This method adds the specified UTXOs to the asset's blacklist, preventing them from being
7639 /// used in future transactions. This is typically used for security purposes when UTXOs are
7640 /// suspected to be compromised or need to be temporarily disabled.
7641 ///
7642 /// # Arguments
7643 /// * `asset_uuid` - The UUID of the asset to blacklist UTXOs for
7644 /// * `utxos` - A slice of `Outpoint` structs representing the UTXOs to blacklist
7645 ///
7646 /// # Returns
7647 /// Returns a vector of `Utxo` structs representing the blacklisted UTXOs with their updated status.
7648 ///
7649 /// # Errors
7650 /// Returns an error if:
7651 /// - Authentication fails or insufficient permissions
7652 /// - The asset UUID is invalid or does not exist
7653 /// - One or more UTXOs are invalid or already blacklisted
7654 /// - The HTTP request fails
7655 /// - The server returns an error status
7656 /// - The response cannot be parsed
7657 ///
7658 /// # Examples
7659 /// ```no_run
7660 /// # use amp_rs::{ApiClient, model::Outpoint};
7661 /// # #[tokio::main]
7662 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7663 /// let client = ApiClient::new().await?;
7664 ///
7665 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
7666 /// let utxos = vec![
7667 /// Outpoint {
7668 /// txid: "abc123...".to_string(),
7669 /// vout: 0,
7670 /// },
7671 /// Outpoint {
7672 /// txid: "def456...".to_string(),
7673 /// vout: 1,
7674 /// },
7675 /// ];
7676 ///
7677 /// let blacklisted_utxos = client.blacklist_asset_utxos(asset_uuid, &utxos).await?;
7678 /// println!("Blacklisted {} UTXOs", blacklisted_utxos.len());
7679 /// # Ok(())
7680 /// # }
7681 /// ```
7682 ///
7683 /// # Related Methods
7684 /// - [`whitelist_asset_utxos`](Self::whitelist_asset_utxos) - Remove UTXOs from blacklist
7685 /// - [`get_asset`](Self::get_asset) - Get asset information including UTXO status
7686 pub async fn blacklist_asset_utxos(
7687 &self,
7688 asset_uuid: &str,
7689 utxos: &[Outpoint],
7690 ) -> Result<Vec<Utxo>, Error> {
7691 self.request_json(
7692 Method::POST,
7693 &["assets", asset_uuid, "utxos", "blacklist"],
7694 Some(utxos),
7695 )
7696 .await
7697 }
7698
7699 /// Removes UTXOs from the asset's blacklist, allowing them to be used in transactions again.
7700 ///
7701 /// This method removes the specified UTXOs from the asset's blacklist, restoring their ability
7702 /// to be used in transactions. This is the reverse operation of blacklisting UTXOs.
7703 ///
7704 /// # Arguments
7705 /// * `asset_uuid` - The UUID of the asset to whitelist UTXOs for
7706 /// * `utxos` - A slice of `Outpoint` structs representing the UTXOs to remove from blacklist
7707 ///
7708 /// # Returns
7709 /// Returns a vector of `Utxo` structs representing the whitelisted UTXOs with their updated status.
7710 ///
7711 /// # Errors
7712 /// Returns an error if:
7713 /// - Authentication fails or insufficient permissions
7714 /// - The asset UUID is invalid or does not exist
7715 /// - One or more UTXOs are invalid or not currently blacklisted
7716 /// - The HTTP request fails
7717 /// - The server returns an error status
7718 /// - The response cannot be parsed
7719 ///
7720 /// # Examples
7721 /// ```no_run
7722 /// # use amp_rs::{ApiClient, model::Outpoint};
7723 /// # #[tokio::main]
7724 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7725 /// let client = ApiClient::new().await?;
7726 ///
7727 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
7728 /// let utxos = vec![
7729 /// Outpoint {
7730 /// txid: "abc123...".to_string(),
7731 /// vout: 0,
7732 /// },
7733 /// ];
7734 ///
7735 /// let whitelisted_utxos = client.whitelist_asset_utxos(asset_uuid, &utxos).await?;
7736 /// println!("Whitelisted {} UTXOs", whitelisted_utxos.len());
7737 /// # Ok(())
7738 /// # }
7739 /// ```
7740 ///
7741 /// # Related Methods
7742 /// - [`blacklist_asset_utxos`](Self::blacklist_asset_utxos) - Add UTXOs to blacklist
7743 /// - [`get_asset`](Self::get_asset) - Get asset information including UTXO status
7744 pub async fn whitelist_asset_utxos(
7745 &self,
7746 asset_uuid: &str,
7747 utxos: &[Outpoint],
7748 ) -> Result<Vec<Utxo>, Error> {
7749 self.request_json(
7750 Method::POST,
7751 &["assets", asset_uuid, "utxos", "whitelist"],
7752 Some(utxos),
7753 )
7754 .await
7755 }
7756
7757 /// Gets the treasury addresses for a specific asset
7758 ///
7759 /// # Arguments
7760 /// * `asset_uuid` - The UUID of the asset to get treasury addresses for
7761 ///
7762 /// # Returns
7763 /// A vector of treasury addresses as strings
7764 ///
7765 /// # Errors
7766 /// Returns an error if:
7767 /// - The asset does not exist
7768 /// - The request fails
7769 /// - The response cannot be parsed
7770 pub async fn get_asset_treasury_addresses(
7771 &self,
7772 asset_uuid: &str,
7773 ) -> Result<Vec<String>, Error> {
7774 self.request_json(
7775 Method::GET,
7776 &["assets", asset_uuid, "treasury-addresses"],
7777 None::<&()>,
7778 )
7779 .await
7780 }
7781
7782 /// Adds treasury addresses to a specific asset
7783 ///
7784 /// # Arguments
7785 /// * `asset_uuid` - The UUID of the asset to add treasury addresses to
7786 /// * `addresses` - A slice of address strings to add as treasury addresses
7787 ///
7788 /// # Returns
7789 /// Returns `Ok(())` on success
7790 ///
7791 /// # Errors
7792 /// Returns an error if:
7793 /// - The asset does not exist
7794 /// - The addresses are invalid
7795 /// - The request fails
7796 /// - Insufficient permissions
7797 pub async fn add_asset_treasury_addresses(
7798 &self,
7799 asset_uuid: &str,
7800 addresses: &[String],
7801 ) -> Result<(), Error> {
7802 self.request_empty(
7803 Method::POST,
7804 &["assets", asset_uuid, "treasury-addresses", "add"],
7805 Some(addresses),
7806 )
7807 .await
7808 }
7809
7810 /// Removes treasury addresses from a specific asset.
7811 ///
7812 /// This method removes the specified addresses from the asset's treasury address list.
7813 /// Treasury addresses are special addresses that can be used for asset management operations
7814 /// such as reissuance and burning.
7815 ///
7816 /// # Arguments
7817 /// * `asset_uuid` - The UUID of the asset to remove treasury addresses from
7818 /// * `addresses` - A slice of address strings to remove from the treasury addresses
7819 ///
7820 /// # Returns
7821 /// Returns `Ok(())` on successful removal.
7822 ///
7823 /// # Errors
7824 /// Returns an error if:
7825 /// - Authentication fails or insufficient permissions
7826 /// - The asset UUID is invalid or does not exist
7827 /// - One or more addresses are invalid or not currently treasury addresses
7828 /// - The HTTP request fails
7829 /// - The server returns an error status
7830 /// - Attempting to remove the last treasury address (if not allowed)
7831 ///
7832 /// # Examples
7833 /// ```no_run
7834 /// # use amp_rs::ApiClient;
7835 /// # #[tokio::main]
7836 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7837 /// let client = ApiClient::new().await?;
7838 ///
7839 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
7840 /// let addresses = vec![
7841 /// "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
7842 /// "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(),
7843 /// ];
7844 ///
7845 /// client.delete_asset_treasury_addresses(asset_uuid, &addresses).await?;
7846 /// println!("Removed {} treasury addresses", addresses.len());
7847 /// # Ok(())
7848 /// # }
7849 /// ```
7850 ///
7851 /// # Related Methods
7852 /// - [`add_asset_treasury_addresses`](Self::add_asset_treasury_addresses) - Add treasury addresses
7853 /// - [`get_asset_treasury_addresses`](Self::get_asset_treasury_addresses) - Get current treasury addresses
7854 /// - [`reissue_asset`](Self::reissue_asset) - Reissue assets using treasury addresses
7855 pub async fn delete_asset_treasury_addresses(
7856 &self,
7857 asset_uuid: &str,
7858 addresses: &[String],
7859 ) -> Result<(), Error> {
7860 self.request_empty(
7861 Method::DELETE,
7862 &["assets", asset_uuid, "treasury-addresses", "delete"],
7863 Some(addresses),
7864 )
7865 .await
7866 }
7867
7868 /// Gets a list of all registered users.
7869 ///
7870 /// # Errors
7871 /// Returns an error if:
7872 /// - Authentication fails
7873 /// - The HTTP request fails
7874 /// - The server returns an error status
7875 /// - The response cannot be parsed
7876 ///
7877 /// # Examples
7878 /// ```no_run
7879 /// # use amp_rs::ApiClient;
7880 /// # #[tokio::main]
7881 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7882 /// let client = ApiClient::new().await?;
7883 ///
7884 /// let users = client.get_registered_users().await?;
7885 /// for user in users {
7886 /// println!("User: {} (ID: {})", user.name, user.id);
7887 /// }
7888 /// # Ok(())
7889 /// # }
7890 /// ```
7891 pub async fn get_registered_users(
7892 &self,
7893 ) -> Result<Vec<crate::model::RegisteredUserResponse>, Error> {
7894 self.request_json(Method::GET, &["registered_users"], None::<&()>)
7895 .await
7896 }
7897
7898 /// Gets a specific registered user by ID.
7899 ///
7900 /// # Arguments
7901 /// * `user_id` - The ID of the registered user to retrieve
7902 ///
7903 /// # Errors
7904 /// Returns an error if:
7905 /// - Authentication fails
7906 /// - The HTTP request fails
7907 /// - The user ID does not exist
7908 /// - The response cannot be parsed
7909 ///
7910 /// # Examples
7911 /// ```no_run
7912 /// # use amp_rs::ApiClient;
7913 /// # #[tokio::main]
7914 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7915 /// let client = ApiClient::new().await?;
7916 ///
7917 /// let user = client.get_registered_user(1).await?;
7918 /// println!("User: {} (ID: {})", user.name, user.id);
7919 /// # Ok(())
7920 /// # }
7921 /// ```
7922 pub async fn get_registered_user(
7923 &self,
7924 user_id: i64,
7925 ) -> Result<crate::model::RegisteredUserResponse, Error> {
7926 self.request_json(
7927 Method::GET,
7928 &["registered_users", &user_id.to_string()],
7929 None::<&()>,
7930 )
7931 .await
7932 }
7933
7934 /// Creates a new registered user in the AMP system.
7935 ///
7936 /// This method creates a new registered user with the provided information. Registered users
7937 /// can be associated with GAIDs, assigned to categories, and receive asset assignments.
7938 ///
7939 /// # Arguments
7940 /// * `new_user` - A `RegisteredUserAdd` struct containing the user information to create
7941 ///
7942 /// # Returns
7943 /// Returns a `RegisteredUserResponse` containing the created user's information including
7944 /// the assigned user ID.
7945 ///
7946 /// # Errors
7947 /// Returns an error if:
7948 /// - Authentication fails or insufficient permissions
7949 /// - The user data is invalid (e.g., missing required fields, invalid email format)
7950 /// - A user with the same identifier already exists
7951 /// - The HTTP request fails
7952 /// - The server returns an error status
7953 /// - The response cannot be parsed
7954 ///
7955 /// # Examples
7956 /// ```no_run
7957 /// # use amp_rs::{ApiClient, model::RegisteredUserAdd};
7958 /// # #[tokio::main]
7959 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7960 /// let client = ApiClient::new().await?;
7961 ///
7962 /// let new_user = RegisteredUserAdd {
7963 /// name: "John Doe".to_string(),
7964 /// gaid: Some("GAbYScu6jkWUND2jo3L4KJxyvo55d".to_string()),
7965 /// is_company: false,
7966 /// };
7967 ///
7968 /// let created_user = client.add_registered_user(&new_user).await?;
7969 /// println!("Created user: {} with ID {}", created_user.name, created_user.id);
7970 /// # Ok(())
7971 /// # }
7972 /// ```
7973 ///
7974 /// # Related Methods
7975 /// - [`get_registered_users`](Self::get_registered_users) - List all registered users
7976 /// - [`edit_registered_user`](Self::edit_registered_user) - Update user information
7977 /// - [`delete_registered_user`](Self::delete_registered_user) - Remove a user
7978 pub async fn add_registered_user(
7979 &self,
7980 new_user: &crate::model::RegisteredUserAdd,
7981 ) -> Result<crate::model::RegisteredUserResponse, Error> {
7982 self.request_json(Method::POST, &["registered_users", "add"], Some(new_user))
7983 .await
7984 }
7985
7986 /// Removes a registered user from the AMP system.
7987 ///
7988 /// This method permanently deletes a registered user and all associated data. This operation
7989 /// cannot be undone. Any GAIDs associated with the user will be disassociated, and any
7990 /// pending assignments may be affected.
7991 ///
7992 /// # Arguments
7993 /// * `user_id` - The ID of the registered user to delete
7994 ///
7995 /// # Returns
7996 /// Returns `Ok(())` on successful deletion.
7997 ///
7998 /// # Errors
7999 /// Returns an error if:
8000 /// - Authentication fails or insufficient permissions
8001 /// - The user ID is invalid or does not exist
8002 /// - The user has active assignments that prevent deletion
8003 /// - The HTTP request fails
8004 /// - The server returns an error status
8005 ///
8006 /// # Examples
8007 /// ```no_run
8008 /// # use amp_rs::ApiClient;
8009 /// # #[tokio::main]
8010 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8011 /// let client = ApiClient::new().await?;
8012 ///
8013 /// let user_id = 123;
8014 /// client.delete_registered_user(user_id).await?;
8015 /// println!("Successfully deleted user with ID {}", user_id);
8016 /// # Ok(())
8017 /// # }
8018 /// ```
8019 ///
8020 /// # Related Methods
8021 /// - [`get_registered_user`](Self::get_registered_user) - Get user information before deletion
8022 /// - [`add_registered_user`](Self::add_registered_user) - Create a new user
8023 /// - [`get_registered_user_summary`](Self::get_registered_user_summary) - Check user's assignments
8024 pub async fn delete_registered_user(&self, user_id: i64) -> Result<(), Error> {
8025 self.request_empty(
8026 Method::DELETE,
8027 &["registered_users", &user_id.to_string(), "delete"],
8028 None::<&()>,
8029 )
8030 .await
8031 }
8032
8033 /// Updates registered user information.
8034 ///
8035 /// This method allows you to modify the information of an existing registered user.
8036 /// Only the fields provided in the edit data will be updated; other fields remain unchanged.
8037 ///
8038 /// # Arguments
8039 /// * `registered_user_id` - The ID of the registered user to update
8040 /// * `edit_data` - A `RegisteredUserEdit` struct containing the fields to update
8041 ///
8042 /// # Returns
8043 /// Returns a `RegisteredUserResponse` containing the updated user information.
8044 ///
8045 /// # Errors
8046 /// Returns an error if:
8047 /// - Authentication fails or insufficient permissions
8048 /// - The user ID is invalid or does not exist
8049 /// - The edit data contains invalid values (e.g., invalid email format)
8050 /// - The HTTP request fails
8051 /// - The server returns an error status
8052 /// - The response cannot be parsed
8053 ///
8054 /// # Examples
8055 /// ```no_run
8056 /// # use amp_rs::{ApiClient, model::RegisteredUserEdit};
8057 /// # #[tokio::main]
8058 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8059 /// let client = ApiClient::new().await?;
8060 ///
8061 /// let user_id = 123;
8062 /// let edit_data = RegisteredUserEdit {
8063 /// name: Some("Jane Doe".to_string()),
8064 /// };
8065 ///
8066 /// let updated_user = client.edit_registered_user(user_id, &edit_data).await?;
8067 /// println!("Updated user: {}", updated_user.name);
8068 /// # Ok(())
8069 /// # }
8070 /// ```
8071 ///
8072 /// # Related Methods
8073 /// - [`get_registered_user`](Self::get_registered_user) - Get current user information
8074 /// - [`add_registered_user`](Self::add_registered_user) - Create a new user
8075 /// - [`delete_registered_user`](Self::delete_registered_user) - Remove a user
8076 pub async fn edit_registered_user(
8077 &self,
8078 registered_user_id: i64,
8079 edit_data: &crate::model::RegisteredUserEdit,
8080 ) -> Result<crate::model::RegisteredUserResponse, Error> {
8081 self.request_json(
8082 Method::PUT,
8083 &["registered_users", ®istered_user_id.to_string(), "edit"],
8084 Some(edit_data),
8085 )
8086 .await
8087 }
8088
8089 /// Gets comprehensive summary information for a registered user including assets and distributions.
8090 ///
8091 /// This method retrieves detailed summary information about a registered user, including
8092 /// their basic information, associated assets, assignment history, and distribution records.
8093 /// This provides a complete overview of the user's activity and holdings in the system.
8094 ///
8095 /// # Arguments
8096 /// * `registered_user_id` - The ID of the registered user to get summary for
8097 ///
8098 /// # Returns
8099 /// Returns a `RegisteredUserSummary` containing:
8100 /// - Basic user information (name, email, etc.)
8101 /// - List of associated GAIDs
8102 /// - Asset assignments and their status
8103 /// - Distribution history
8104 /// - Balance information
8105 /// - Activity timestamps
8106 ///
8107 /// # Errors
8108 /// Returns an error if:
8109 /// - Authentication fails or insufficient permissions
8110 /// - The user ID is invalid or does not exist
8111 /// - The HTTP request fails
8112 /// - The server returns an error status
8113 /// - The response cannot be parsed
8114 ///
8115 /// # Examples
8116 /// ```no_run
8117 /// # use amp_rs::ApiClient;
8118 /// # #[tokio::main]
8119 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8120 /// let client = ApiClient::new().await?;
8121 ///
8122 /// let user_id = 123;
8123 /// let summary = client.get_registered_user_summary(user_id).await?;
8124 ///
8125 /// println!("Asset UUID: {}", summary.asset_uuid);
8126 /// println!("Asset ID: {}", summary.asset_id);
8127 /// println!("Asset assignments: {}", summary.assignments.len());
8128 /// println!("Distributions received: {}", summary.distributions.len());
8129 /// # Ok(())
8130 /// # }
8131 /// ```
8132 ///
8133 /// # Related Methods
8134 /// - [`get_registered_user`](Self::get_registered_user) - Get basic user information
8135 /// - [`get_registered_user_gaids`](Self::get_registered_user_gaids) - Get only GAIDs
8136 /// - [`get_asset_assignments`](Self::get_asset_assignments) - Get assignments for specific asset
8137 pub async fn get_registered_user_summary(
8138 &self,
8139 registered_user_id: i64,
8140 ) -> Result<crate::model::RegisteredUserSummary, Error> {
8141 self.request_json(
8142 Method::GET,
8143 &[
8144 "registered_users",
8145 ®istered_user_id.to_string(),
8146 "summary",
8147 ],
8148 None::<&()>,
8149 )
8150 .await
8151 }
8152
8153 /// Gets all GAIDs (Green Address IDs) associated with a registered user.
8154 ///
8155 /// This method retrieves a list of all GAIDs that are currently associated with the specified
8156 /// registered user. GAIDs are unique identifiers that can be used to receive assets and
8157 /// track ownership.
8158 ///
8159 /// # Arguments
8160 /// * `registered_user_id` - The ID of the registered user to get GAIDs for
8161 ///
8162 /// # Returns
8163 /// Returns a vector of GAID strings associated with the user.
8164 ///
8165 /// # Errors
8166 /// Returns an error if:
8167 /// - Authentication fails or insufficient permissions
8168 /// - The user ID is invalid or does not exist
8169 /// - The HTTP request fails
8170 /// - The server returns an error status
8171 /// - The response cannot be parsed
8172 ///
8173 /// # Examples
8174 /// ```no_run
8175 /// # use amp_rs::ApiClient;
8176 /// # #[tokio::main]
8177 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8178 /// let client = ApiClient::new().await?;
8179 ///
8180 /// let user_id = 123;
8181 /// let gaids = client.get_registered_user_gaids(user_id).await?;
8182 ///
8183 /// println!("User {} has {} associated GAIDs:", user_id, gaids.len());
8184 /// for gaid in gaids {
8185 /// println!(" - {}", gaid);
8186 /// }
8187 /// # Ok(())
8188 /// # }
8189 /// ```
8190 ///
8191 /// # Related Methods
8192 /// - [`add_gaid_to_registered_user`](Self::add_gaid_to_registered_user) - Associate a GAID with user
8193 /// - [`set_default_gaid_for_registered_user`](Self::set_default_gaid_for_registered_user) - Set default GAID
8194 /// - [`get_gaid_registered_user`](Self::get_gaid_registered_user) - Find user by GAID
8195 /// - [`validate_gaid`](Self::validate_gaid) - Validate GAID format
8196 pub async fn get_registered_user_gaids(
8197 &self,
8198 registered_user_id: i64,
8199 ) -> Result<Vec<String>, Error> {
8200 self.request_json(
8201 Method::GET,
8202 &["registered_users", ®istered_user_id.to_string(), "gaids"],
8203 None::<&()>,
8204 )
8205 .await
8206 }
8207
8208 /// Associates a GAID with a registered user.
8209 ///
8210 /// # Arguments
8211 /// * `registered_user_id` - The ID of the registered user
8212 /// * `gaid` - The GAID to associate with the user
8213 ///
8214 /// # Errors
8215 ///
8216 /// Returns an error if:
8217 /// - Authentication fails
8218 /// - The HTTP request fails
8219 /// - The server returns an error status
8220 /// - The registered user ID is invalid
8221 /// - The GAID is invalid or already associated
8222 pub async fn add_gaid_to_registered_user(
8223 &self,
8224 registered_user_id: i64,
8225 gaid: &str,
8226 ) -> Result<(), Error> {
8227 let request = GaidRequest {
8228 gaid: gaid.to_string(),
8229 };
8230
8231 self.request_empty(
8232 Method::POST,
8233 &[
8234 "registered_users",
8235 ®istered_user_id.to_string(),
8236 "gaids",
8237 "add",
8238 ],
8239 Some(request),
8240 )
8241 .await
8242 }
8243
8244 /// Sets an existing GAID as the default for a registered user.
8245 ///
8246 /// This method allows you to designate a specific GAID as the primary/default
8247 /// GAID for a registered user. The GAID must already be associated with the user.
8248 ///
8249 /// # Arguments
8250 /// * `registered_user_id` - The ID of the registered user
8251 /// * `gaid` - The GAID to set as default
8252 ///
8253 /// # Returns
8254 /// Returns `Ok(())` if the operation is successful.
8255 ///
8256 /// # Errors
8257 /// Returns an error if:
8258 /// - Authentication fails
8259 /// - The HTTP request fails
8260 /// - The server returns an error status
8261 /// - The registered user ID is invalid
8262 /// - The GAID is not associated with the user
8263 pub async fn set_default_gaid_for_registered_user(
8264 &self,
8265 registered_user_id: i64,
8266 gaid: &str,
8267 ) -> Result<(), Error> {
8268 let request = GaidRequest {
8269 gaid: gaid.to_string(),
8270 };
8271
8272 self.request_empty(
8273 Method::POST,
8274 &[
8275 "registered_users",
8276 ®istered_user_id.to_string(),
8277 "gaids",
8278 "set-default",
8279 ],
8280 Some(request),
8281 )
8282 .await
8283 }
8284
8285 /// Retrieves the registered user associated with a GAID
8286 ///
8287 /// # Arguments
8288 /// * `gaid` - The GAID to look up
8289 ///
8290 /// # Returns
8291 /// Returns the registered user data if the GAID is associated with a user
8292 ///
8293 /// # Errors
8294 /// This function will return an error if:
8295 /// - The GAID has no associated user
8296 /// - The GAID is invalid
8297 /// - Network or authentication errors occur
8298 pub async fn get_gaid_registered_user(
8299 &self,
8300 gaid: &str,
8301 ) -> Result<crate::model::RegisteredUserResponse, Error> {
8302 self.request_json(
8303 Method::GET,
8304 &["gaids", gaid, "registered_user"],
8305 None::<&()>,
8306 )
8307 .await
8308 }
8309
8310 /// Gets the balance information for a specific GAID.
8311 ///
8312 /// This method retrieves all asset balances associated with the given GAID,
8313 /// including confirmed balances and any lost outputs.
8314 ///
8315 /// # Arguments
8316 /// * `gaid` - The GAID to query balance for
8317 ///
8318 /// # Returns
8319 /// Returns a `Balance` struct containing confirmed balances and lost outputs
8320 ///
8321 /// # Errors
8322 /// Returns an error if:
8323 /// - The GAID is invalid
8324 /// - Network or authentication errors occur
8325 /// - The response cannot be parsed
8326 ///
8327 /// # Examples
8328 /// ```no_run
8329 /// # use amp_rs::ApiClient;
8330 /// # #[tokio::main]
8331 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8332 /// let client = ApiClient::new().await?;
8333 ///
8334 /// let gaid = "GAbYScu6jkWUND2jo3L4KJxyvo55d";
8335 /// let balance = client.get_gaid_balance(gaid).await?;
8336 ///
8337 /// println!("GAID {} has {} balance entries", gaid, balance.len());
8338 /// for entry in balance {
8339 /// println!("Asset {}: {} units", entry.asset_id, entry.balance);
8340 /// }
8341 /// # Ok(())
8342 /// # }
8343 /// ```
8344 pub async fn get_gaid_balance(&self, gaid: &str) -> Result<Balance, Error> {
8345 self.request_json(Method::GET, &["gaids", gaid, "balance"], None::<&()>)
8346 .await
8347 }
8348
8349 /// Retrieves the specific asset balance for a GAID
8350 ///
8351 /// # Arguments
8352 /// * `gaid` - The GAID to query
8353 /// * `asset_uuid` - The UUID of the asset to query
8354 ///
8355 /// # Returns
8356 /// Returns the specific asset balance information
8357 ///
8358 /// # Errors
8359 /// Returns an error if:
8360 /// - The GAID is invalid
8361 /// - The asset UUID is invalid
8362 /// - Network or authentication errors occur
8363 /// - The response cannot be parsed
8364 pub async fn get_gaid_asset_balance(
8365 &self,
8366 gaid: &str,
8367 asset_uuid: &str,
8368 ) -> Result<Ownership, Error> {
8369 // Try to get the response as a GaidBalanceEntry first, then convert to Ownership
8370 let balance_entry: GaidBalanceEntry = self
8371 .request_json(
8372 Method::GET,
8373 &["gaids", gaid, "balance", asset_uuid],
8374 None::<&()>,
8375 )
8376 .await?;
8377
8378 // Convert GaidBalanceEntry to Ownership format
8379 Ok(Ownership {
8380 owner: gaid.to_string(),
8381 amount: balance_entry.balance,
8382 gaid: Some(gaid.to_string()),
8383 })
8384 }
8385
8386 /// Gets a list of all categories.
8387 ///
8388 /// # Returns
8389 /// Returns a vector of `CategoryResponse` objects
8390 ///
8391 /// # Errors
8392 /// Returns an error if:
8393 /// - Authentication fails
8394 /// - The HTTP request fails
8395 /// - The response cannot be parsed
8396 ///
8397 /// # Examples
8398 /// ```no_run
8399 /// # use amp_rs::ApiClient;
8400 /// # #[tokio::main]
8401 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8402 /// let client = ApiClient::new().await?;
8403 ///
8404 /// let categories = client.get_categories().await?;
8405 /// for category in categories {
8406 /// println!("Category: {} (ID: {})", category.name, category.id);
8407 /// if let Some(desc) = category.description {
8408 /// println!(" Description: {}", desc);
8409 /// }
8410 /// }
8411 /// # Ok(())
8412 /// # }
8413 /// ```
8414 pub async fn get_categories(&self) -> Result<Vec<CategoryResponse>, Error> {
8415 self.request_json(Method::GET, &["categories"], None::<&()>)
8416 .await
8417 }
8418
8419 /// Creates a new category for organizing users and assets.
8420 ///
8421 /// This method creates a new category that can be used to group registered users and assets
8422 /// for organizational purposes. Categories help manage permissions and provide logical
8423 /// groupings for assets and users.
8424 ///
8425 /// # Arguments
8426 /// * `new_category` - A `CategoryAdd` struct containing the category information to create
8427 ///
8428 /// # Returns
8429 /// Returns a `CategoryResponse` containing the created category information including
8430 /// the assigned category ID.
8431 ///
8432 /// # Errors
8433 /// Returns an error if:
8434 /// - Authentication fails or insufficient permissions
8435 /// - The category data is invalid (e.g., missing name, invalid characters)
8436 /// - A category with the same name already exists
8437 /// - The HTTP request fails
8438 /// - The server returns an error status
8439 /// - The response cannot be parsed
8440 ///
8441 /// # Examples
8442 /// ```no_run
8443 /// # use amp_rs::{ApiClient, model::CategoryAdd};
8444 /// # #[tokio::main]
8445 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8446 /// let client = ApiClient::new().await?;
8447 ///
8448 /// let new_category = CategoryAdd {
8449 /// name: "Premium Users".to_string(),
8450 /// description: Some("High-value users with special privileges".to_string()),
8451 /// };
8452 ///
8453 /// let created_category = client.add_category(&new_category).await?;
8454 /// println!("Created category: {} with ID {}", created_category.name, created_category.id);
8455 /// # Ok(())
8456 /// # }
8457 /// ```
8458 ///
8459 /// # Related Methods
8460 /// - [`get_categories`](Self::get_categories) - List all categories
8461 /// - [`edit_category`](Self::edit_category) - Update category information
8462 /// - [`delete_category`](Self::delete_category) - Remove a category
8463 /// - [`add_registered_user_to_category`](Self::add_registered_user_to_category) - Add users to category
8464 pub async fn add_category(
8465 &self,
8466 new_category: &CategoryAdd,
8467 ) -> Result<CategoryResponse, Error> {
8468 self.request_json(Method::POST, &["categories", "add"], Some(new_category))
8469 .await
8470 }
8471
8472 /// Gets a specific category by ID.
8473 ///
8474 /// This method retrieves detailed information about a specific category, including
8475 /// its name, description, and associated users and assets.
8476 ///
8477 /// # Arguments
8478 /// * `category_id` - The ID of the category to retrieve
8479 ///
8480 /// # Returns
8481 /// Returns a `CategoryResponse` containing the category information including:
8482 /// - Category ID, name, and description
8483 /// - List of associated registered users
8484 /// - List of associated assets
8485 /// - Creation and modification timestamps
8486 ///
8487 /// # Errors
8488 /// Returns an error if:
8489 /// - Authentication fails or insufficient permissions
8490 /// - The category ID is invalid or does not exist
8491 /// - The HTTP request fails
8492 /// - The server returns an error status
8493 /// - The response cannot be parsed
8494 ///
8495 /// # Examples
8496 /// ```no_run
8497 /// # use amp_rs::ApiClient;
8498 /// # #[tokio::main]
8499 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8500 /// let client = ApiClient::new().await?;
8501 ///
8502 /// let category_id = 1;
8503 /// let category = client.get_category(category_id).await?;
8504 ///
8505 /// println!("Category: {} (ID: {})", category.name, category.id);
8506 /// if let Some(desc) = category.description {
8507 /// println!("Description: {}", desc);
8508 /// }
8509 /// println!("Users: {}, Assets: {}", category.registered_users.len(), category.assets.len());
8510 /// # Ok(())
8511 /// # }
8512 /// ```
8513 ///
8514 /// # Related Methods
8515 /// - [`get_categories`](Self::get_categories) - List all categories
8516 /// - [`add_category`](Self::add_category) - Create a new category
8517 /// - [`edit_category`](Self::edit_category) - Update category information
8518 /// - [`delete_category`](Self::delete_category) - Remove a category
8519 pub async fn get_category(&self, category_id: i64) -> Result<CategoryResponse, Error> {
8520 self.request_json(
8521 Method::GET,
8522 &["categories", &category_id.to_string()],
8523 None::<&()>,
8524 )
8525 .await
8526 }
8527
8528 /// Updates category information.
8529 ///
8530 /// This method allows you to modify the information of an existing category.
8531 /// Only the fields provided in the edit data will be updated; other fields remain unchanged.
8532 ///
8533 /// # Arguments
8534 /// * `category_id` - The ID of the category to update
8535 /// * `edit_category` - A `CategoryEdit` struct containing the fields to update
8536 ///
8537 /// # Returns
8538 /// Returns a `CategoryResponse` containing the updated category information.
8539 ///
8540 /// # Errors
8541 /// Returns an error if:
8542 /// - Authentication fails or insufficient permissions
8543 /// - The category ID is invalid or does not exist
8544 /// - The edit data contains invalid values (e.g., empty name, invalid characters)
8545 /// - A category with the new name already exists (if name is being changed)
8546 /// - The HTTP request fails
8547 /// - The server returns an error status
8548 /// - The response cannot be parsed
8549 ///
8550 /// # Examples
8551 /// ```no_run
8552 /// # use amp_rs::{ApiClient, model::CategoryEdit};
8553 /// # #[tokio::main]
8554 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8555 /// let client = ApiClient::new().await?;
8556 ///
8557 /// let category_id = 1;
8558 /// let edit_data = CategoryEdit {
8559 /// name: Some("VIP Users".to_string()),
8560 /// description: Some("Very important users with premium access".to_string()),
8561 /// };
8562 ///
8563 /// let updated_category = client.edit_category(category_id, &edit_data).await?;
8564 /// println!("Updated category: {}", updated_category.name);
8565 /// # Ok(())
8566 /// # }
8567 /// ```
8568 ///
8569 /// # Related Methods
8570 /// - [`get_category`](Self::get_category) - Get current category information
8571 /// - [`add_category`](Self::add_category) - Create a new category
8572 /// - [`delete_category`](Self::delete_category) - Remove a category
8573 pub async fn edit_category(
8574 &self,
8575 category_id: i64,
8576 edit_category: &CategoryEdit,
8577 ) -> Result<CategoryResponse, Error> {
8578 self.request_json(
8579 Method::PUT,
8580 &["categories", &category_id.to_string(), "edit"],
8581 Some(edit_category),
8582 )
8583 .await
8584 }
8585
8586 /// Removes a category from the system.
8587 ///
8588 /// This method permanently deletes a category. All users and assets associated with the
8589 /// category will be disassociated, but the users and assets themselves are not deleted.
8590 /// This operation cannot be undone.
8591 ///
8592 /// # Arguments
8593 /// * `category_id` - The ID of the category to delete
8594 ///
8595 /// # Returns
8596 /// Returns `Ok(())` on successful deletion.
8597 ///
8598 /// # Errors
8599 /// Returns an error if:
8600 /// - Authentication fails or insufficient permissions
8601 /// - The category ID is invalid or does not exist
8602 /// - The category is still in use and cannot be deleted (depending on system configuration)
8603 /// - The HTTP request fails
8604 /// - The server returns an error status
8605 ///
8606 /// # Examples
8607 /// ```no_run
8608 /// # use amp_rs::ApiClient;
8609 /// # #[tokio::main]
8610 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8611 /// let client = ApiClient::new().await?;
8612 ///
8613 /// let category_id = 1;
8614 /// client.delete_category(category_id).await?;
8615 /// println!("Successfully deleted category with ID {}", category_id);
8616 /// # Ok(())
8617 /// # }
8618 /// ```
8619 ///
8620 /// # Related Methods
8621 /// - [`get_category`](Self::get_category) - Get category information before deletion
8622 /// - [`add_category`](Self::add_category) - Create a new category
8623 /// - [`remove_registered_user_from_category`](Self::remove_registered_user_from_category) - Remove users first
8624 /// - [`remove_asset_from_category`](Self::remove_asset_from_category) - Remove assets first
8625 pub async fn delete_category(&self, category_id: i64) -> Result<(), Error> {
8626 self.request_empty(
8627 Method::DELETE,
8628 &["categories", &category_id.to_string(), "delete"],
8629 None::<&()>,
8630 )
8631 .await
8632 }
8633
8634 /// Associates a registered user with a category.
8635 ///
8636 /// This method adds a registered user to a category, allowing for organized grouping
8637 /// of users. Users can belong to multiple categories, and categories can contain
8638 /// multiple users.
8639 ///
8640 /// # Arguments
8641 /// * `category_id` - The ID of the category to add the user to
8642 /// * `user_id` - The ID of the registered user to add to the category
8643 ///
8644 /// # Returns
8645 /// Returns a `CategoryResponse` containing the updated category information including
8646 /// the newly added user.
8647 ///
8648 /// # Errors
8649 /// Returns an error if:
8650 /// - Authentication fails or insufficient permissions
8651 /// - The category ID is invalid or does not exist
8652 /// - The user ID is invalid or does not exist
8653 /// - The user is already associated with the category
8654 /// - The HTTP request fails
8655 /// - The server returns an error status
8656 /// - The response cannot be parsed
8657 ///
8658 /// # Examples
8659 /// ```no_run
8660 /// # use amp_rs::ApiClient;
8661 /// # #[tokio::main]
8662 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8663 /// let client = ApiClient::new().await?;
8664 ///
8665 /// let category_id = 1;
8666 /// let user_id = 123;
8667 ///
8668 /// let updated_category = client.add_registered_user_to_category(category_id, user_id).await?;
8669 /// println!("Added user {} to category '{}'", user_id, updated_category.name);
8670 /// println!("Category now has {} users", updated_category.registered_users.len());
8671 /// # Ok(())
8672 /// # }
8673 /// ```
8674 ///
8675 /// # Related Methods
8676 /// - [`remove_registered_user_from_category`](Self::remove_registered_user_from_category) - Remove user from category
8677 /// - [`get_category`](Self::get_category) - Get category information including users
8678 /// - [`get_registered_user`](Self::get_registered_user) - Get user information
8679 pub async fn add_registered_user_to_category(
8680 &self,
8681 category_id: i64,
8682 user_id: i64,
8683 ) -> Result<CategoryResponse, Error> {
8684 self.request_json(
8685 Method::PUT,
8686 &[
8687 "categories",
8688 &category_id.to_string(),
8689 "registered_users",
8690 &user_id.to_string(),
8691 "add",
8692 ],
8693 None::<&()>,
8694 )
8695 .await
8696 }
8697
8698 /// Removes a registered user from a category.
8699 ///
8700 /// This method disassociates a registered user from a category. The user remains in the
8701 /// system but is no longer part of the specified category. This does not affect the user's
8702 /// association with other categories.
8703 ///
8704 /// # Arguments
8705 /// * `category_id` - The ID of the category to remove the user from
8706 /// * `user_id` - The ID of the registered user to remove from the category
8707 ///
8708 /// # Returns
8709 /// Returns a `CategoryResponse` containing the updated category information without
8710 /// the removed user.
8711 ///
8712 /// # Errors
8713 /// Returns an error if:
8714 /// - Authentication fails or insufficient permissions
8715 /// - The category ID is invalid or does not exist
8716 /// - The user ID is invalid or does not exist
8717 /// - The user is not currently associated with the category
8718 /// - The HTTP request fails
8719 /// - The server returns an error status
8720 /// - The response cannot be parsed
8721 ///
8722 /// # Examples
8723 /// ```no_run
8724 /// # use amp_rs::ApiClient;
8725 /// # #[tokio::main]
8726 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8727 /// let client = ApiClient::new().await?;
8728 ///
8729 /// let category_id = 1;
8730 /// let user_id = 123;
8731 ///
8732 /// let updated_category = client.remove_registered_user_from_category(category_id, user_id).await?;
8733 /// println!("Removed user {} from category '{}'", user_id, updated_category.name);
8734 /// println!("Category now has {} users", updated_category.registered_users.len());
8735 /// # Ok(())
8736 /// # }
8737 /// ```
8738 ///
8739 /// # Related Methods
8740 /// - [`add_registered_user_to_category`](Self::add_registered_user_to_category) - Add user to category
8741 /// - [`get_category`](Self::get_category) - Get category information including users
8742 /// - [`get_registered_user`](Self::get_registered_user) - Get user information
8743 pub async fn remove_registered_user_from_category(
8744 &self,
8745 category_id: i64,
8746 user_id: i64,
8747 ) -> Result<CategoryResponse, Error> {
8748 self.request_json(
8749 Method::PUT,
8750 &[
8751 "categories",
8752 &category_id.to_string(),
8753 "registered_users",
8754 &user_id.to_string(),
8755 "remove",
8756 ],
8757 None::<&()>,
8758 )
8759 .await
8760 }
8761
8762 /// Associates an asset with a category.
8763 ///
8764 /// This method adds an asset to a category, allowing for organized grouping of assets.
8765 /// Assets can belong to multiple categories, and categories can contain multiple assets.
8766 /// This helps with asset management and permission organization.
8767 ///
8768 /// # Arguments
8769 /// * `category_id` - The ID of the category to add the asset to
8770 /// * `asset_uuid` - The UUID of the asset to add to the category
8771 ///
8772 /// # Returns
8773 /// Returns a `CategoryResponse` containing the updated category information including
8774 /// the newly added asset.
8775 ///
8776 /// # Errors
8777 /// Returns an error if:
8778 /// - Authentication fails or insufficient permissions
8779 /// - The category ID is invalid or does not exist
8780 /// - The asset UUID is invalid or does not exist
8781 /// - The asset is already associated with the category
8782 /// - The HTTP request fails
8783 /// - The server returns an error status
8784 /// - The response cannot be parsed
8785 ///
8786 /// # Examples
8787 /// ```no_run
8788 /// # use amp_rs::ApiClient;
8789 /// # #[tokio::main]
8790 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8791 /// let client = ApiClient::new().await?;
8792 ///
8793 /// let category_id = 1;
8794 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
8795 ///
8796 /// let updated_category = client.add_asset_to_category(category_id, asset_uuid).await?;
8797 /// println!("Added asset {} to category '{}'", asset_uuid, updated_category.name);
8798 /// println!("Category now has {} assets", updated_category.assets.len());
8799 /// # Ok(())
8800 /// # }
8801 /// ```
8802 ///
8803 /// # Related Methods
8804 /// - [`remove_asset_from_category`](Self::remove_asset_from_category) - Remove asset from category
8805 /// - [`get_category`](Self::get_category) - Get category information including assets
8806 /// - [`get_asset`](Self::get_asset) - Get asset information
8807 pub async fn add_asset_to_category(
8808 &self,
8809 category_id: i64,
8810 asset_uuid: &str,
8811 ) -> Result<CategoryResponse, Error> {
8812 self.request_json(
8813 Method::PUT,
8814 &[
8815 "categories",
8816 &category_id.to_string(),
8817 "assets",
8818 asset_uuid,
8819 "add",
8820 ],
8821 None::<&()>,
8822 )
8823 .await
8824 }
8825
8826 /// Removes an asset from a category.
8827 ///
8828 /// This method disassociates an asset from a category. The asset remains in the system
8829 /// but is no longer part of the specified category. This does not affect the asset's
8830 /// association with other categories.
8831 ///
8832 /// # Arguments
8833 /// * `category_id` - The ID of the category to remove the asset from
8834 /// * `asset_uuid` - The UUID of the asset to remove from the category
8835 ///
8836 /// # Returns
8837 /// Returns a `CategoryResponse` containing the updated category information without
8838 /// the removed asset.
8839 ///
8840 /// # Errors
8841 /// Returns an error if:
8842 /// - Authentication fails or insufficient permissions
8843 /// - The category ID is invalid or does not exist
8844 /// - The asset UUID is invalid or does not exist
8845 /// - The asset is not currently associated with the category
8846 /// - The HTTP request fails
8847 /// - The server returns an error status
8848 /// - The response cannot be parsed
8849 ///
8850 /// # Examples
8851 /// ```no_run
8852 /// # use amp_rs::ApiClient;
8853 /// # #[tokio::main]
8854 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8855 /// let client = ApiClient::new().await?;
8856 ///
8857 /// let category_id = 1;
8858 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
8859 ///
8860 /// let updated_category = client.remove_asset_from_category(category_id, asset_uuid).await?;
8861 /// println!("Removed asset {} from category '{}'", asset_uuid, updated_category.name);
8862 /// println!("Category now has {} assets", updated_category.assets.len());
8863 /// # Ok(())
8864 /// # }
8865 /// ```
8866 ///
8867 /// # Related Methods
8868 /// - [`add_asset_to_category`](Self::add_asset_to_category) - Add asset to category
8869 /// - [`get_category`](Self::get_category) - Get category information including assets
8870 /// - [`get_asset`](Self::get_asset) - Get asset information
8871 pub async fn remove_asset_from_category(
8872 &self,
8873 category_id: i64,
8874 asset_uuid: &str,
8875 ) -> Result<CategoryResponse, Error> {
8876 self.request_json(
8877 Method::PUT,
8878 &[
8879 "categories",
8880 &category_id.to_string(),
8881 "assets",
8882 asset_uuid,
8883 "remove",
8884 ],
8885 None::<&()>,
8886 )
8887 .await
8888 }
8889
8890 /// Validates a GAID (Green Address ID).
8891 ///
8892 /// # Arguments
8893 /// * `gaid` - The GAID string to validate
8894 ///
8895 /// # Returns
8896 /// Returns a `ValidateGaidResponse` indicating whether the GAID is valid
8897 ///
8898 /// # Errors
8899 /// Returns an error if:
8900 /// - Authentication fails
8901 /// - The HTTP request fails
8902 /// - The response cannot be parsed
8903 ///
8904 /// # Examples
8905 /// ```no_run
8906 /// # use amp_rs::ApiClient;
8907 /// # #[tokio::main]
8908 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8909 /// let client = ApiClient::new().await?;
8910 ///
8911 /// let gaid = "GAbYScu6jkWUND2jo3L4KJxyvo55d";
8912 /// let validation = client.validate_gaid(gaid).await?;
8913 ///
8914 /// if validation.is_valid {
8915 /// println!("GAID {} is valid", gaid);
8916 /// } else {
8917 /// println!("GAID {} is invalid: {:?}", gaid, validation.error);
8918 /// }
8919 /// # Ok(())
8920 /// # }
8921 /// ```
8922 pub async fn validate_gaid(
8923 &self,
8924 gaid: &str,
8925 ) -> Result<crate::model::ValidateGaidResponse, Error> {
8926 self.request_json(Method::GET, &["gaids", gaid, "validate"], None::<&()>)
8927 .await
8928 }
8929
8930 /// Gets the address associated with a GAID.
8931 ///
8932 /// # Arguments
8933 /// * `gaid` - The GAID to get the address for
8934 ///
8935 /// # Returns
8936 /// Returns an `AddressGaidResponse` containing the address
8937 ///
8938 /// # Errors
8939 /// Returns an error if:
8940 /// - The GAID is invalid
8941 /// - Authentication fails
8942 /// - The HTTP request fails
8943 /// - The response cannot be parsed
8944 ///
8945 /// # Examples
8946 /// ```no_run
8947 /// # use amp_rs::ApiClient;
8948 /// # #[tokio::main]
8949 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8950 /// let client = ApiClient::new().await?;
8951 ///
8952 /// let gaid = "GAbYScu6jkWUND2jo3L4KJxyvo55d";
8953 /// let address_response = client.get_gaid_address(gaid).await?;
8954 ///
8955 /// println!("Address for GAID {}: {}", gaid, address_response.address);
8956 /// # Ok(())
8957 /// # }
8958 /// ```
8959 pub async fn get_gaid_address(
8960 &self,
8961 gaid: &str,
8962 ) -> Result<crate::model::AddressGaidResponse, Error> {
8963 self.request_json(Method::GET, &["gaids", gaid, "address"], None::<&()>)
8964 .await
8965 }
8966
8967 /// Gets a list of all managers.
8968 ///
8969 /// # Returns
8970 /// Returns a vector of `Manager` objects
8971 ///
8972 /// # Errors
8973 /// Returns an error if:
8974 /// - Authentication fails
8975 /// - The HTTP request fails
8976 /// - The response cannot be parsed
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 managers = client.get_managers().await?;
8986 /// for manager in managers {
8987 /// println!("Manager: {} (ID: {})", manager.username, manager.id);
8988 /// }
8989 /// # Ok(())
8990 /// # }
8991 /// ```
8992 pub async fn get_managers(&self) -> Result<Vec<crate::model::Manager>, Error> {
8993 self.request_json(Method::GET, &["managers"], None::<&()>)
8994 .await
8995 }
8996
8997 /// Creates a new manager.
8998 ///
8999 /// # Arguments
9000 /// * `new_manager` - The manager creation request containing username and password
9001 ///
9002 /// # Returns
9003 /// Returns the created `Manager` object
9004 ///
9005 /// # Errors
9006 /// Returns an error if:
9007 /// - Authentication fails
9008 /// - The HTTP request fails
9009 /// - The manager creation request is invalid
9010 /// - The response cannot be parsed
9011 ///
9012 /// # Examples
9013 /// ```no_run
9014 /// # use amp_rs::{ApiClient, model::ManagerCreate};
9015 /// # #[tokio::main]
9016 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9017 /// let client = ApiClient::new().await?;
9018 ///
9019 /// let new_manager = ManagerCreate {
9020 /// username: "new_manager".to_string(),
9021 /// password: "secure_password".to_string(),
9022 /// };
9023 ///
9024 /// let manager = client.create_manager(&new_manager).await?;
9025 /// println!("Created manager: {} (ID: {})", manager.username, manager.id);
9026 /// # Ok(())
9027 /// # }
9028 /// ```
9029 pub async fn create_manager(
9030 &self,
9031 new_manager: &crate::model::ManagerCreate,
9032 ) -> Result<crate::model::Manager, Error> {
9033 self.request_json(Method::POST, &["managers", "create"], Some(new_manager))
9034 .await
9035 }
9036
9037 /// Gets all assignments for a specific asset.
9038 ///
9039 /// # Arguments
9040 /// * `asset_uuid` - The UUID of the asset to get assignments for
9041 ///
9042 /// # Returns
9043 /// Returns a vector of `Assignment` objects
9044 ///
9045 /// # Errors
9046 /// Returns an error if:
9047 /// - Authentication fails
9048 /// - The HTTP request fails
9049 /// - The asset UUID is invalid
9050 /// - The response cannot be parsed
9051 ///
9052 /// # Examples
9053 /// ```no_run
9054 /// # use amp_rs::ApiClient;
9055 /// # #[tokio::main]
9056 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9057 /// let client = ApiClient::new().await?;
9058 ///
9059 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
9060 /// let assignments = client.get_asset_assignments(asset_uuid).await?;
9061 ///
9062 /// for assignment in assignments {
9063 /// println!("Assignment ID: {}, Amount: {}", assignment.id, assignment.amount);
9064 /// }
9065 /// # Ok(())
9066 /// # }
9067 /// ```
9068 pub async fn get_asset_assignments(&self, asset_uuid: &str) -> Result<Vec<Assignment>, Error> {
9069 self.request_json(
9070 Method::GET,
9071 &["assets", asset_uuid, "assignments"],
9072 None::<&()>,
9073 )
9074 .await
9075 }
9076
9077 /// Creates multiple asset assignments in batch.
9078 ///
9079 /// This method creates multiple asset assignments for the specified asset. Each assignment
9080 /// allocates a specific amount of the asset to a registered user. The assignments are
9081 /// created individually due to API limitations, but this method handles the batch processing
9082 /// automatically.
9083 ///
9084 /// # Arguments
9085 /// * `asset_uuid` - The UUID of the asset to create assignments for
9086 /// * `requests` - A slice of `CreateAssetAssignmentRequest` structs containing assignment details
9087 ///
9088 /// # Returns
9089 /// Returns a vector of `Assignment` structs representing the created assignments with their
9090 /// assigned IDs and status information.
9091 ///
9092 /// # Errors
9093 /// Returns an error if:
9094 /// - Authentication fails or insufficient permissions
9095 /// - The asset UUID is invalid or does not exist
9096 /// - Any assignment request contains invalid data (e.g., invalid user ID, negative amount)
9097 /// - Insufficient asset balance for the total requested assignments
9098 /// - Any individual assignment creation fails
9099 /// - The HTTP request fails
9100 /// - The server returns an error status
9101 /// - The response cannot be parsed
9102 ///
9103 /// # Examples
9104 /// ```no_run
9105 /// # use amp_rs::{ApiClient, model::CreateAssetAssignmentRequest};
9106 /// # #[tokio::main]
9107 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9108 /// let client = ApiClient::new().await?;
9109 ///
9110 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
9111 /// let requests = vec![
9112 /// CreateAssetAssignmentRequest {
9113 /// registered_user: 123,
9114 /// amount: 1000,
9115 /// vesting_timestamp: None,
9116 /// ready_for_distribution: false,
9117 /// },
9118 /// CreateAssetAssignmentRequest {
9119 /// registered_user: 456,
9120 /// amount: 500,
9121 /// vesting_timestamp: None,
9122 /// ready_for_distribution: true,
9123 /// },
9124 /// ];
9125 ///
9126 /// let assignments = client.create_asset_assignments(asset_uuid, &requests).await?;
9127 /// println!("Created {} assignments", assignments.len());
9128 /// for assignment in assignments {
9129 /// println!("Assignment {}: {} units to user {}",
9130 /// assignment.id, assignment.amount, assignment.registered_user);
9131 /// }
9132 /// # Ok(())
9133 /// # }
9134 /// ```
9135 ///
9136 /// # Related Methods
9137 /// - [`get_asset_assignments`](Self::get_asset_assignments) - List all assignments for an asset
9138 /// - [`delete_asset_assignment`](Self::delete_asset_assignment) - Remove an assignment
9139 /// - [`edit_asset_assignment`](Self::edit_asset_assignment) - Update assignment details
9140 /// - [`set_assignment_ready_for_distribution`](Self::set_assignment_ready_for_distribution) - Mark for distribution
9141 pub async fn create_asset_assignments(
9142 &self,
9143 asset_uuid: &str,
9144 requests: &[CreateAssetAssignmentRequest],
9145 ) -> Result<Vec<Assignment>, Error> {
9146 use crate::model::CreateAssetAssignmentRequestWrapper;
9147
9148 // The API only supports maximum length 1 per request, so we need to break
9149 // multiple assignments into separate CreateAssetAssignmentRequestWrapper instances
9150 let mut all_assignments = Vec::new();
9151
9152 for request in requests {
9153 let wrapper = CreateAssetAssignmentRequestWrapper {
9154 assignments: vec![request.clone()],
9155 };
9156
9157 let assignments: Vec<Assignment> = self
9158 .request_json(
9159 Method::POST,
9160 &["assets", asset_uuid, "assignments", "create"],
9161 Some(&wrapper),
9162 )
9163 .await?;
9164
9165 all_assignments.extend(assignments);
9166 }
9167
9168 Ok(all_assignments)
9169 }
9170
9171 /// Gets a specific asset assignment by asset UUID and assignment ID.
9172 ///
9173 /// This method sends a GET request to retrieve detailed information about a specific asset
9174 /// assignment. Asset assignments represent the allocation of assets to users or entities,
9175 /// including information such as the assigned amount, recipient details, and assignment status.
9176 ///
9177 /// # Arguments
9178 /// * `asset_uuid` - The UUID of the asset for which to retrieve the assignment
9179 /// * `assignment_id` - The ID of the specific assignment to retrieve
9180 ///
9181 /// # Returns
9182 /// Returns an `Assignment` struct containing the assignment details including:
9183 /// - Assignment ID and amount
9184 /// - Recipient information
9185 /// - Assignment status and metadata
9186 /// - Creation and modification timestamps
9187 ///
9188 /// # Errors
9189 /// Returns an error if:
9190 /// - Authentication fails
9191 /// - The HTTP request fails
9192 /// - The server returns an error status
9193 /// - The asset UUID is invalid or does not exist
9194 /// - The assignment ID is invalid or does not exist
9195 /// - The assignment is not accessible to the current user
9196 /// - The response cannot be parsed as a valid Assignment
9197 ///
9198 /// # Example
9199 /// ```no_run
9200 /// # use amp_rs::ApiClient;
9201 /// # #[tokio::main]
9202 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9203 /// let client = ApiClient::new().await?;
9204 ///
9205 /// // Retrieve assignment with ID "123" for asset "550e8400-e29b-41d4-a716-446655440000"
9206 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
9207 /// let assignment_id = "123";
9208 ///
9209 /// let assignment = client.get_asset_assignment(asset_uuid, assignment_id).await?;
9210 ///
9211 /// println!("Assignment ID: {}", assignment.id);
9212 /// println!("Assigned amount: {}", assignment.amount);
9213 /// println!("Registered user: {}", assignment.registered_user);
9214 /// # Ok(())
9215 /// # }
9216 /// ```
9217 pub async fn get_asset_assignment(
9218 &self,
9219 asset_uuid: &str,
9220 assignment_id: &str,
9221 ) -> Result<Assignment, Error> {
9222 self.request_json(
9223 Method::GET,
9224 &["assets", asset_uuid, "assignments", assignment_id],
9225 None::<&()>,
9226 )
9227 .await
9228 }
9229
9230 /// Creates a distribution for an asset with the specified assignments.
9231 ///
9232 /// This method initiates the distribution creation process by sending assignment details
9233 /// to the AMP API. The API will return a distribution UUID and address mappings that
9234 /// can be used for subsequent transaction creation and confirmation steps.
9235 ///
9236 /// # Arguments
9237 /// * `asset_uuid` - The UUID of the asset to distribute
9238 /// * `assignments` - A vector of `AssetDistributionAssignment` structs containing user IDs, addresses, and amounts
9239 ///
9240 /// # Returns
9241 /// Returns a `DistributionResponse` containing:
9242 /// - `distribution_uuid` - Unique identifier for the created distribution
9243 /// - `map_address_amount` - Mapping of addresses to amounts to be distributed
9244 /// - `map_address_asset` - Mapping of addresses to asset IDs
9245 /// - `asset_id` - The asset ID for the distribution
9246 ///
9247 /// # Errors
9248 /// Returns an `AmpError` if:
9249 /// - Authentication fails or insufficient permissions
9250 /// - The asset UUID is invalid or does not exist
9251 /// - Assignment data is invalid (e.g., invalid user IDs, negative amounts, invalid addresses)
9252 /// - Insufficient asset balance for the requested distribution
9253 /// - The HTTP request fails
9254 /// - The server returns an error status
9255 /// - The response cannot be parsed
9256 ///
9257 /// # Examples
9258 /// ```no_run
9259 /// # use amp_rs::{ApiClient, model::AssetDistributionAssignment, AmpError};
9260 /// # #[tokio::main]
9261 /// # async fn main() -> Result<(), AmpError> {
9262 /// let client = ApiClient::new().await.map_err(AmpError::from)?;
9263 ///
9264 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
9265 /// let assignments = vec![
9266 /// AssetDistributionAssignment {
9267 /// user_id: "user123".to_string(),
9268 /// address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
9269 /// amount: 100.0,
9270 /// },
9271 /// AssetDistributionAssignment {
9272 /// user_id: "user456".to_string(),
9273 /// address: "lq1qq3xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
9274 /// amount: 50.0,
9275 /// },
9276 /// ];
9277 ///
9278 /// let distribution_response = client.create_distribution(asset_uuid, assignments).await?;
9279 /// println!("Created distribution: {}", distribution_response.distribution_uuid);
9280 /// println!("Asset ID: {}", distribution_response.asset_id);
9281 /// # Ok(())
9282 /// # }
9283 /// ```
9284 ///
9285 /// # Related Methods
9286 /// - [`get_asset_assignments`](Self::get_asset_assignments) - List assignments for an asset
9287 /// - [`create_asset_assignments`](Self::create_asset_assignments) - Create new assignments
9288 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
9289 pub async fn create_distribution(
9290 &self,
9291 asset_uuid: &str,
9292 assignments: Vec<crate::model::AssetDistributionAssignment>,
9293 ) -> Result<crate::model::DistributionResponse, AmpError> {
9294 use crate::model::{CreateDistributionRequest, DistributionAssignmentRequest};
9295
9296 let create_span = tracing::debug_span!(
9297 "create_distribution",
9298 asset_uuid = %asset_uuid,
9299 assignment_count = assignments.len()
9300 );
9301 let _enter = create_span.enter();
9302
9303 tracing::debug!(
9304 "Creating distribution for asset {} with {} assignments",
9305 asset_uuid,
9306 assignments.len()
9307 );
9308
9309 // Validate inputs
9310 if asset_uuid.is_empty() {
9311 tracing::error!("Distribution creation failed: empty asset UUID");
9312 return Err(AmpError::validation("Asset UUID cannot be empty"));
9313 }
9314
9315 if assignments.is_empty() {
9316 tracing::error!("Distribution creation failed: empty assignments");
9317 return Err(AmpError::validation("Assignments cannot be empty"));
9318 }
9319
9320 // Convert AssetDistributionAssignment to DistributionAssignmentRequest
9321 // The API expects user_uuid field, but our input uses user_id
9322 tracing::trace!("Converting {} assignments to API format", assignments.len());
9323 let mut total_amount = 0.0;
9324 let api_assignments: Vec<DistributionAssignmentRequest> = assignments
9325 .into_iter()
9326 .enumerate()
9327 .map(
9328 #[allow(clippy::cognitive_complexity)]
9329 |(index, assignment)| {
9330 tracing::trace!(
9331 "Converting assignment {}: user_id={}, address={}, amount={}",
9332 index,
9333 assignment.user_id,
9334 assignment.address,
9335 assignment.amount
9336 );
9337
9338 // Validate assignment data
9339 if assignment.user_id.is_empty() {
9340 tracing::error!("Assignment {} has empty user_id", index);
9341 return Err(AmpError::validation(format!(
9342 "Assignment {index} has empty user_id"
9343 )));
9344 }
9345 if assignment.address.is_empty() {
9346 tracing::error!("Assignment {} has empty address", index);
9347 return Err(AmpError::validation(format!(
9348 "Assignment {index} has empty address"
9349 )));
9350 }
9351 if assignment.amount <= 0.0 {
9352 tracing::error!(
9353 "Assignment {} has non-positive amount: {}",
9354 index,
9355 assignment.amount
9356 );
9357 return Err(AmpError::validation(format!(
9358 "Assignment {} has non-positive amount: {}",
9359 index, assignment.amount
9360 )));
9361 }
9362
9363 total_amount += assignment.amount;
9364
9365 Ok(DistributionAssignmentRequest {
9366 user_uuid: assignment.user_id, // Map user_id to user_uuid for API
9367 amount: assignment.amount,
9368 address: assignment.address,
9369 })
9370 },
9371 )
9372 .collect::<Result<Vec<_>, AmpError>>()?;
9373
9374 tracing::debug!(
9375 "Converted {} assignments successfully, total amount: {}",
9376 api_assignments.len(),
9377 total_amount
9378 );
9379
9380 let request = CreateDistributionRequest {
9381 assignments: api_assignments,
9382 };
9383
9384 tracing::debug!("Sending distribution creation request to AMP API");
9385 let api_call_start = std::time::Instant::now();
9386
9387 // Make the API call
9388 let response: crate::model::DistributionResponse = self
9389 .request_json(
9390 Method::GET,
9391 &["assets", asset_uuid, "distributions", "create"],
9392 Some(&request),
9393 )
9394 .await
9395 .map_err(
9396 #[allow(clippy::cognitive_complexity)]
9397 |e| {
9398 let api_call_duration = api_call_start.elapsed();
9399 let error_msg =
9400 format!("Failed to create distribution after {api_call_duration:?}: {e}");
9401 tracing::error!("{}", error_msg);
9402
9403 // Check for specific API error patterns
9404 let error_str = e.to_string();
9405 if error_str.contains("404") || error_str.contains("not found") {
9406 tracing::error!(
9407 "Asset {} not found - verify asset UUID is correct",
9408 asset_uuid
9409 );
9410 } else if error_str.contains("400") || error_str.contains("bad request") {
9411 tracing::error!("Bad request - check assignment data format and values");
9412 } else if error_str.contains("401") || error_str.contains("unauthorized") {
9413 tracing::error!("Unauthorized - check API credentials and token validity");
9414 } else if error_str.contains("403") || error_str.contains("forbidden") {
9415 tracing::error!("Forbidden - check permissions for asset distribution");
9416 } else if error_str.contains("429") || error_str.contains("rate limit") {
9417 tracing::error!("Rate limited - wait before retrying");
9418 } else if error_str.contains("500") || error_str.contains("internal server") {
9419 tracing::error!(
9420 "Server error - this may be a temporary issue, retry may help"
9421 );
9422 }
9423
9424 AmpError::api(error_msg)
9425 },
9426 )?;
9427
9428 let api_call_duration = api_call_start.elapsed();
9429 tracing::info!(
9430 "Successfully created distribution: {} (took {:?})",
9431 response.distribution_uuid,
9432 api_call_duration
9433 );
9434
9435 // Validate response data
9436 if response.distribution_uuid.is_empty() {
9437 tracing::error!("API returned empty distribution UUID");
9438 return Err(AmpError::api("API returned empty distribution UUID"));
9439 }
9440
9441 if response.asset_id.is_empty() {
9442 tracing::error!("API returned empty asset ID");
9443 return Err(AmpError::api("API returned empty asset ID"));
9444 }
9445
9446 if response.map_address_amount.is_empty() {
9447 tracing::error!("API returned empty address mapping");
9448 return Err(AmpError::api("API returned empty address mapping"));
9449 }
9450
9451 tracing::debug!(
9452 "Distribution response validated - {} addresses mapped, asset_id: {}",
9453 response.map_address_amount.len(),
9454 response.asset_id
9455 );
9456
9457 Ok(response)
9458 }
9459
9460 /// Confirms a distribution with transaction and change data.
9461 ///
9462 /// This method submits the final confirmation for a distribution by providing
9463 /// the transaction details and any change UTXOs to the AMP API. This completes
9464 /// the distribution workflow after the transaction has been broadcast and confirmed
9465 /// on the blockchain.
9466 ///
9467 /// # Arguments
9468 /// * `asset_uuid` - The UUID of the asset being distributed
9469 /// * `distribution_uuid` - The UUID of the distribution to confirm (from `create_distribution` response)
9470 /// * `tx_data` - Transaction data containing details and txid from the blockchain
9471 /// * `change_data` - Vector of change UTXOs from the transaction
9472 ///
9473 /// # Errors
9474 /// Returns an error if:
9475 /// - Authentication fails
9476 /// - The asset UUID or distribution UUID is invalid
9477 /// - The transaction data is invalid or incomplete
9478 /// - The HTTP request fails
9479 /// - The server returns an error status
9480 /// - The response cannot be parsed
9481 ///
9482 /// # Examples
9483 /// ```no_run
9484 /// # use amp_rs::{ApiClient, model::{AmpTxData, Unspent}, AmpError};
9485 /// # #[tokio::main]
9486 /// # async fn main() -> Result<(), AmpError> {
9487 /// # let client = ApiClient::new().await?;
9488 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
9489 /// let distribution_uuid = "dist-550e8400-e29b-41d4-a716-446655440000";
9490 ///
9491 /// // Transaction data for AMP API confirmation
9492 /// let tx_data = AmpTxData {
9493 /// details: serde_json::json!([{
9494 /// "account": "",
9495 /// "address": "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
9496 /// "category": "send",
9497 /// "amount": -100.0,
9498 /// "vout": 0,
9499 /// "fee": -0.001
9500 /// }]),
9501 /// txid: "abc123def456...".to_string(),
9502 /// };
9503 ///
9504 /// // Change UTXOs from Elements node listunspent call
9505 /// let change_data = vec![
9506 /// Unspent {
9507 /// txid: "abc123def456...".to_string(),
9508 /// vout: 1,
9509 /// amount: 25.0,
9510 /// asset: "asset_id_hex".to_string(),
9511 /// address: "change_address".to_string(),
9512 /// spendable: true,
9513 /// confirmations: Some(2),
9514 /// scriptpubkey: Some("76a914...88ac".to_string()),
9515 /// redeemscript: None,
9516 /// witnessscript: None,
9517 /// amountblinder: None,
9518 /// assetblinder: None,
9519 /// }
9520 /// ];
9521 ///
9522 /// client.confirm_distribution(asset_uuid, distribution_uuid, tx_data, change_data).await?;
9523 /// println!("Distribution confirmed successfully");
9524 /// # Ok(())
9525 /// # }
9526 /// ```
9527 ///
9528 /// # Related Methods
9529 /// - [`create_distribution`](Self::create_distribution) - Create a new distribution
9530 /// - [`get_asset_assignments`](Self::get_asset_assignments) - List assignments for an asset
9531 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
9532 pub async fn confirm_distribution(
9533 &self,
9534 asset_uuid: &str,
9535 distribution_uuid: &str,
9536 tx_data: crate::model::AmpTxData,
9537 change_data: Vec<crate::model::Unspent>,
9538 ) -> Result<(), AmpError> {
9539 use crate::model::ConfirmDistributionRequest;
9540
9541 let confirm_span = tracing::debug_span!(
9542 "confirm_distribution",
9543 asset_uuid = %asset_uuid,
9544 distribution_uuid = %distribution_uuid,
9545 txid = %tx_data.txid,
9546 change_count = change_data.len()
9547 );
9548 let _enter = confirm_span.enter();
9549
9550 tracing::debug!(
9551 "Confirming distribution {} for asset {} with txid {} ({} change UTXOs)",
9552 distribution_uuid,
9553 asset_uuid,
9554 tx_data.txid,
9555 change_data.len()
9556 );
9557
9558 // Validate inputs
9559 if asset_uuid.is_empty() {
9560 tracing::error!("Distribution confirmation failed: empty asset UUID");
9561 return Err(AmpError::validation("Asset UUID cannot be empty"));
9562 }
9563
9564 if distribution_uuid.is_empty() {
9565 tracing::error!("Distribution confirmation failed: empty distribution UUID");
9566 return Err(AmpError::validation("Distribution UUID cannot be empty"));
9567 }
9568
9569 if tx_data.txid.is_empty() {
9570 tracing::error!("Distribution confirmation failed: empty transaction ID");
9571 return Err(AmpError::validation("Transaction ID cannot be empty"));
9572 }
9573
9574 // Log transaction details for debugging
9575 tracing::debug!("Transaction details array: {:?}", tx_data.details);
9576
9577 // Log change data details
9578 if change_data.is_empty() {
9579 tracing::debug!("No change UTXOs to include in confirmation");
9580 } else {
9581 let total_change: f64 = change_data.iter().map(|utxo| utxo.amount).sum();
9582 tracing::debug!(
9583 "Change data - {} UTXOs, total amount: {}",
9584 change_data.len(),
9585 total_change
9586 );
9587
9588 for (i, utxo) in change_data.iter().enumerate() {
9589 tracing::trace!(
9590 "Change UTXO {}: txid={}, vout={}, amount={}, spendable={}",
9591 i,
9592 utxo.txid,
9593 utxo.vout,
9594 utxo.amount,
9595 utxo.spendable
9596 );
9597 }
9598 }
9599
9600 let request = ConfirmDistributionRequest {
9601 tx_data: tx_data.clone(),
9602 change_data: change_data.clone(),
9603 };
9604
9605 tracing::debug!("Sending distribution confirmation request to AMP API");
9606 let api_call_start = std::time::Instant::now();
9607
9608 // Make the API call
9609 self.request_empty(
9610 Method::POST,
9611 &["assets", asset_uuid, "distributions", distribution_uuid, "confirm"],
9612 Some(&request),
9613 )
9614 .await
9615 .map_err(#[allow(clippy::cognitive_complexity)] |e| {
9616 let api_call_duration = api_call_start.elapsed();
9617 let error_msg = format!(
9618 "Failed to confirm distribution {} after {:?}: {}. IMPORTANT: Transaction {} was successful on blockchain. Use this txid to manually retry confirmation.",
9619 distribution_uuid, api_call_duration, e, tx_data.txid
9620 );
9621 tracing::error!("{}", error_msg);
9622
9623 // Check for specific API error patterns
9624 let error_str = e.to_string();
9625 if error_str.contains("404") || error_str.contains("not found") {
9626 tracing::error!("Distribution {} not found - verify distribution UUID is correct", distribution_uuid);
9627 } else if error_str.contains("400") || error_str.contains("bad request") {
9628 tracing::error!("Bad request - check transaction data format and change data");
9629 } else if error_str.contains("409") || error_str.contains("conflict") {
9630 tracing::error!("Conflict - distribution may already be confirmed");
9631 } else if error_str.contains("422") || error_str.contains("unprocessable") {
9632 tracing::error!("Unprocessable entity - check transaction confirmations and data validity");
9633 } else if error_str.contains("500") || error_str.contains("internal server") {
9634 tracing::error!("Server error - this may be a temporary issue, retry with txid: {}", tx_data.txid);
9635 }
9636
9637 AmpError::api(error_msg)
9638 })?;
9639
9640 let api_call_duration = api_call_start.elapsed();
9641 tracing::info!(
9642 "Successfully confirmed distribution: {} for asset: {} with txid: {} (took {:?})",
9643 distribution_uuid,
9644 asset_uuid,
9645 tx_data.txid,
9646 api_call_duration
9647 );
9648
9649 Ok(())
9650 }
9651
9652 /// Cancels an in-progress distribution for an asset.
9653 ///
9654 /// This method cancels a distribution that is currently in progress (unconfirmed status).
9655 /// Once a distribution is cancelled, it cannot be confirmed and the assigned amounts
9656 /// become available for new distributions.
9657 ///
9658 /// # Arguments
9659 /// * `asset_uuid` - The UUID of the asset
9660 /// * `distribution_uuid` - The UUID of the distribution to cancel
9661 ///
9662 /// # Returns
9663 /// Returns `Ok(())` if the distribution was successfully cancelled.
9664 ///
9665 /// # Errors
9666 /// Returns an error if:
9667 /// - Authentication fails
9668 /// - The HTTP request fails
9669 /// - The server returns an error status
9670 /// - The distribution is not found
9671 /// - The distribution is already confirmed and cannot be cancelled
9672 ///
9673 /// # Examples
9674 /// ```no_run
9675 /// use amp_rs::ApiClient;
9676 ///
9677 /// #[tokio::main]
9678 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
9679 /// let client = ApiClient::new().await?;
9680 ///
9681 /// client.cancel_distribution(
9682 /// "asset-uuid-123",
9683 /// "distribution-uuid-456"
9684 /// ).await?;
9685 ///
9686 /// println!("Distribution cancelled successfully");
9687 /// Ok(())
9688 /// # }
9689 /// ```
9690 #[allow(clippy::cognitive_complexity)]
9691 pub async fn cancel_distribution(
9692 &self,
9693 asset_uuid: &str,
9694 distribution_uuid: &str,
9695 ) -> Result<(), AmpError> {
9696 let cancel_span = tracing::debug_span!(
9697 "cancel_distribution",
9698 asset_uuid = %asset_uuid,
9699 distribution_uuid = %distribution_uuid
9700 );
9701 let _enter = cancel_span.enter();
9702
9703 tracing::debug!(
9704 "Cancelling distribution {} for asset {}",
9705 distribution_uuid,
9706 asset_uuid
9707 );
9708
9709 // Validate inputs
9710 if asset_uuid.is_empty() {
9711 tracing::error!("Distribution cancellation failed: empty asset UUID");
9712 return Err(AmpError::validation("Asset UUID cannot be empty"));
9713 }
9714
9715 if distribution_uuid.is_empty() {
9716 tracing::error!("Distribution cancellation failed: empty distribution UUID");
9717 return Err(AmpError::validation("Distribution UUID cannot be empty"));
9718 }
9719
9720 let api_call_start = std::time::Instant::now();
9721
9722 self.request_empty(
9723 Method::DELETE,
9724 &[
9725 "assets",
9726 asset_uuid,
9727 "distributions",
9728 distribution_uuid,
9729 "cancel",
9730 ],
9731 None::<&()>,
9732 )
9733 .await
9734 .map_err(|e| {
9735 let api_call_duration = api_call_start.elapsed();
9736 let error_msg = format!(
9737 "Failed to cancel distribution {distribution_uuid} for asset {asset_uuid} after {api_call_duration:?}: {e}"
9738 );
9739 tracing::error!("{}", error_msg);
9740
9741 // Check for specific API error patterns
9742 let error_str = e.to_string();
9743 if error_str.contains("404") || error_str.contains("not found") {
9744 tracing::error!(
9745 "Distribution {} not found - verify distribution UUID is correct",
9746 distribution_uuid
9747 );
9748 } else if error_str.contains("400") || error_str.contains("bad request") {
9749 tracing::error!("Bad request - distribution may already be confirmed or invalid");
9750 } else if error_str.contains("409") || error_str.contains("conflict") {
9751 tracing::error!(
9752 "Conflict - distribution may already be confirmed and cannot be cancelled"
9753 );
9754 } else if error_str.contains("422") || error_str.contains("unprocessable") {
9755 tracing::error!(
9756 "Unprocessable entity - distribution is in a state that cannot be cancelled"
9757 );
9758 }
9759
9760 AmpError::api(error_msg)
9761 })?;
9762
9763 let api_call_duration = api_call_start.elapsed();
9764 tracing::info!(
9765 "Successfully cancelled distribution: {} for asset: {} (took {:?})",
9766 distribution_uuid,
9767 asset_uuid,
9768 api_call_duration
9769 );
9770
9771 Ok(())
9772 }
9773
9774 /// Gets all distributions for a specific asset.
9775 ///
9776 /// This method retrieves all distributions (both confirmed and unconfirmed) for the specified asset.
9777 /// This is useful for checking if there are any in-progress distributions before deleting an asset.
9778 ///
9779 /// # Arguments
9780 /// * `asset_uuid` - The UUID of the asset to get distributions for
9781 ///
9782 /// # Returns
9783 /// Returns a vector of `Distribution` objects for the asset.
9784 ///
9785 /// # Errors
9786 /// Returns an error if:
9787 /// - Authentication fails
9788 /// - The HTTP request fails
9789 /// - The server returns an error status
9790 /// - The response cannot be parsed
9791 ///
9792 /// # Examples
9793 /// ```no_run
9794 /// use amp_rs::ApiClient;
9795 ///
9796 /// #[tokio::main]
9797 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
9798 /// let client = ApiClient::new().await?;
9799 ///
9800 /// let distributions = client.get_asset_distributions("asset-uuid-123").await?;
9801 ///
9802 /// for distribution in distributions {
9803 /// println!("Distribution: {} - Status: {:?}",
9804 /// distribution.distribution_uuid,
9805 /// distribution.distribution_status);
9806 /// }
9807 /// Ok(())
9808 /// }
9809 /// ```
9810 pub async fn get_asset_distributions(
9811 &self,
9812 asset_uuid: &str,
9813 ) -> Result<Vec<crate::model::Distribution>, Error> {
9814 let distributions_span = tracing::debug_span!(
9815 "get_asset_distributions",
9816 asset_uuid = %asset_uuid
9817 );
9818 let _enter = distributions_span.enter();
9819
9820 tracing::debug!("Getting distributions for asset {}", asset_uuid);
9821
9822 // Validate input
9823 if asset_uuid.is_empty() {
9824 tracing::error!("Get distributions failed: empty asset UUID");
9825 return Err(Error::RequestFailed(
9826 "Asset UUID cannot be empty".to_string(),
9827 ));
9828 }
9829
9830 self.request_json(
9831 Method::GET,
9832 &["assets", asset_uuid, "distributions"],
9833 None::<&()>,
9834 )
9835 .await
9836 }
9837
9838 /// Gets a specific distribution by UUID for an asset.
9839 ///
9840 /// This method retrieves detailed information about a specific distribution,
9841 /// including its status, UUID, and associated transactions.
9842 ///
9843 /// # Arguments
9844 /// * `asset_uuid` - The UUID of the asset
9845 /// * `distribution_uuid` - The UUID of the distribution to retrieve
9846 ///
9847 /// # Returns
9848 /// Returns a `Distribution` struct containing:
9849 /// - `distribution_uuid` - The unique identifier for the distribution
9850 /// - `distribution_status` - Current status of the distribution
9851 /// - `transactions` - List of transactions associated with the distribution
9852 ///
9853 /// # Errors
9854 /// Returns an error if:
9855 /// - Authentication fails
9856 /// - The HTTP request fails
9857 /// - The server returns an error status
9858 /// - The response cannot be parsed as JSON
9859 /// - The asset UUID or distribution UUID is empty
9860 ///
9861 /// # Examples
9862 /// ```no_run
9863 /// # use amp_rs::ApiClient;
9864 /// # #[tokio::main]
9865 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9866 /// let client = ApiClient::new().await?;
9867 ///
9868 /// let distribution = client.get_asset_distribution(
9869 /// "asset-uuid-123",
9870 /// "distribution-uuid-456"
9871 /// ).await?;
9872 ///
9873 /// println!("Distribution: {} - Status: {:?}",
9874 /// distribution.distribution_uuid,
9875 /// distribution.distribution_status);
9876 /// # Ok(())
9877 /// # }
9878 /// ```
9879 ///
9880 /// # Related Methods
9881 /// - [`get_asset_distributions`](Self::get_asset_distributions) - List all distributions for an asset
9882 /// - [`create_distribution`](Self::create_distribution) - Create a new distribution
9883 /// - [`confirm_distribution`](Self::confirm_distribution) - Confirm a distribution
9884 /// - [`cancel_distribution`](Self::cancel_distribution) - Cancel a distribution
9885 #[allow(clippy::cognitive_complexity)]
9886 pub async fn get_asset_distribution(
9887 &self,
9888 asset_uuid: &str,
9889 distribution_uuid: &str,
9890 ) -> Result<crate::model::Distribution, Error> {
9891 let distribution_span = tracing::debug_span!(
9892 "get_asset_distribution",
9893 asset_uuid = %asset_uuid,
9894 distribution_uuid = %distribution_uuid
9895 );
9896 let _enter = distribution_span.enter();
9897
9898 tracing::debug!(
9899 "Getting distribution {} for asset {}",
9900 distribution_uuid,
9901 asset_uuid
9902 );
9903
9904 // Validate inputs
9905 if asset_uuid.is_empty() {
9906 tracing::error!("Get distribution failed: empty asset UUID");
9907 return Err(Error::RequestFailed(
9908 "Asset UUID cannot be empty".to_string(),
9909 ));
9910 }
9911
9912 if distribution_uuid.is_empty() {
9913 tracing::error!("Get distribution failed: empty distribution UUID");
9914 return Err(Error::RequestFailed(
9915 "Distribution UUID cannot be empty".to_string(),
9916 ));
9917 }
9918
9919 self.request_json(
9920 Method::GET,
9921 &["assets", asset_uuid, "distributions", distribution_uuid],
9922 None::<&()>,
9923 )
9924 .await
9925 }
9926
9927 /// Gets a specific manager by ID.
9928 ///
9929 /// # Arguments
9930 /// * `manager_id` - The ID of the manager to retrieve
9931 ///
9932 /// # Errors
9933 /// Returns an error if:
9934 /// - Authentication fails
9935 /// - The HTTP request fails
9936 /// - The server returns an error status
9937 /// - The response cannot be parsed as JSON
9938 pub async fn get_manager(&self, manager_id: i64) -> Result<crate::model::Manager, Error> {
9939 self.request_json(
9940 Method::GET,
9941 &["managers", &manager_id.to_string()],
9942 None::<&()>,
9943 )
9944 .await
9945 }
9946
9947 /// Removes a manager's permissions to modify a specific asset.
9948 ///
9949 /// This method revokes a manager's access to a specific asset, preventing them from
9950 /// performing asset management operations such as creating assignments, managing ownership,
9951 /// or modifying asset properties. The manager will no longer be able to access this asset
9952 /// through their management interface.
9953 ///
9954 /// # Arguments
9955 /// * `manager_id` - The ID of the manager to remove permissions from
9956 /// * `asset_uuid` - The UUID of the asset to remove permissions for
9957 ///
9958 /// # Returns
9959 /// Returns `Ok(())` on successful permission removal.
9960 ///
9961 /// # Errors
9962 /// Returns an error if:
9963 /// - Authentication fails or insufficient permissions
9964 /// - The manager ID is invalid or does not exist
9965 /// - The asset UUID is invalid or does not exist
9966 /// - The manager does not currently have permissions for this asset
9967 /// - The HTTP request fails
9968 /// - The server returns an error status
9969 ///
9970 /// # Examples
9971 /// ```no_run
9972 /// # use amp_rs::ApiClient;
9973 /// # #[tokio::main]
9974 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9975 /// let client = ApiClient::new().await?;
9976 ///
9977 /// let manager_id = 123;
9978 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
9979 ///
9980 /// client.manager_remove_asset(manager_id, asset_uuid).await?;
9981 /// println!("Removed asset {} from manager {}", asset_uuid, manager_id);
9982 /// # Ok(())
9983 /// # }
9984 /// ```
9985 ///
9986 /// # Related Methods
9987 /// - [`add_asset_to_manager`](Self::add_asset_to_manager) - Grant manager permissions for an asset
9988 /// - [`get_manager`](Self::get_manager) - Get manager information including current assets
9989 /// - [`revoke_manager`](Self::revoke_manager) - Remove all asset permissions from manager
9990 /// - [`lock_manager`](Self::lock_manager) - Lock manager account
9991 pub async fn manager_remove_asset(
9992 &self,
9993 manager_id: i64,
9994 asset_uuid: &str,
9995 ) -> Result<(), Error> {
9996 self.request_empty(
9997 Method::POST,
9998 &[
9999 "managers",
10000 &manager_id.to_string(),
10001 "assets",
10002 asset_uuid,
10003 "remove",
10004 ],
10005 None::<&()>,
10006 )
10007 .await
10008 }
10009
10010 /// Revokes all asset permissions for a manager.
10011 ///
10012 /// This method first retrieves the manager's current asset permissions,
10013 /// then removes the manager's access to each asset they currently have access to.
10014 ///
10015 /// # Arguments
10016 /// * `manager_id` - The ID of the manager to revoke permissions for
10017 ///
10018 /// # Errors
10019 /// Returns an error if:
10020 /// - Authentication fails
10021 /// - The HTTP request fails
10022 /// - The server returns an error status
10023 /// - Any individual asset removal fails
10024 pub async fn revoke_manager(&self, manager_id: i64) -> Result<(), Error> {
10025 // First, get the manager to see which assets they have access to
10026 let manager = self.get_manager(manager_id).await?;
10027
10028 // Remove the manager's access to each asset
10029 for asset_uuid in &manager.assets {
10030 self.manager_remove_asset(manager_id, asset_uuid).await?;
10031 }
10032
10033 Ok(())
10034 }
10035
10036 /// Gets the current manager information as raw JSON.
10037 ///
10038 /// This method calls the `/managers/me` endpoint to retrieve information
10039 /// about the currently authenticated manager.
10040 ///
10041 /// # Errors
10042 /// Returns an error if:
10043 /// - Authentication fails
10044 /// - The HTTP request fails
10045 /// - The server returns an error status
10046 /// - The response cannot be parsed as JSON
10047 pub async fn get_current_manager_raw(&self) -> Result<serde_json::Value, Error> {
10048 self.request_json(Method::GET, &["managers", "me"], None::<&()>)
10049 .await
10050 }
10051
10052 /// Locks a manager account to prevent further operations.
10053 ///
10054 /// This method sends a PUT request to lock the specified manager, preventing any further
10055 /// operations on that manager account. This is typically used for security purposes or
10056 /// when a manager needs to be temporarily disabled.
10057 ///
10058 /// # Arguments
10059 /// * `manager_id` - The ID of the manager to lock
10060 ///
10061 /// # Returns
10062 /// Returns `Ok(())` if the manager was successfully locked.
10063 ///
10064 /// # Errors
10065 /// Returns an error if:
10066 /// - Authentication fails
10067 /// - The HTTP request fails
10068 /// - The server returns an error status
10069 /// - The manager ID is invalid or does not exist
10070 /// - The manager is already locked
10071 ///
10072 /// # Example
10073 /// ```no_run
10074 /// # use amp_rs::ApiClient;
10075 /// # #[tokio::main]
10076 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10077 /// let client = ApiClient::new().await?;
10078 ///
10079 /// // Lock manager with ID 123
10080 /// client.lock_manager(123).await?;
10081 /// println!("Manager 123 has been locked successfully");
10082 /// # Ok(())
10083 /// # }
10084 /// ```
10085 pub async fn lock_manager(&self, manager_id: i64) -> Result<(), Error> {
10086 self.request_empty(
10087 Method::PUT,
10088 &["managers", &manager_id.to_string(), "lock"],
10089 None::<&()>,
10090 )
10091 .await
10092 }
10093
10094 /// Unlocks a manager account.
10095 ///
10096 /// # Arguments
10097 /// * `manager_id` - The ID of the manager to unlock
10098 ///
10099 /// # Errors
10100 /// Returns an error if:
10101 /// - Authentication fails
10102 /// - The HTTP request fails
10103 /// - The server returns an error status
10104 pub async fn unlock_manager(&self, manager_id: i64) -> Result<(), Error> {
10105 self.request_empty(
10106 Method::PUT,
10107 &["managers", &manager_id.to_string(), "unlock"],
10108 None::<&()>,
10109 )
10110 .await
10111 }
10112
10113 /// Authorizes a manager to manage a specific asset.
10114 ///
10115 /// This method sends a PUT request to authorize the specified manager to manage the given asset.
10116 /// Once authorized, the manager will have permissions to perform operations on the asset such as
10117 /// creating assignments, managing ownership, and other asset-related operations.
10118 ///
10119 /// # Arguments
10120 /// * `manager_id` - The ID of the manager to authorize
10121 /// * `asset_uuid` - The UUID of the asset to add to the manager's authorized assets
10122 ///
10123 /// # Returns
10124 /// Returns `Ok(())` if the manager was successfully authorized for the asset.
10125 ///
10126 /// # Errors
10127 /// Returns an error if:
10128 /// - Authentication fails or insufficient permissions
10129 /// - The HTTP request fails
10130 /// - The server returns an error status
10131 /// - The manager ID is invalid or does not exist
10132 /// - The asset UUID is invalid or does not exist
10133 /// - The manager is already authorized for this asset
10134 /// - The manager is locked and cannot be modified
10135 ///
10136 /// # Examples
10137 /// ```no_run
10138 /// # use amp_rs::ApiClient;
10139 /// # #[tokio::main]
10140 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10141 /// let client = ApiClient::new().await?;
10142 ///
10143 /// // Authorize manager 123 to manage asset with UUID "550e8400-e29b-41d4-a716-446655440000"
10144 /// let manager_id = 123;
10145 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10146 ///
10147 /// client.add_asset_to_manager(manager_id, asset_uuid).await?;
10148 /// println!("Manager {} is now authorized to manage asset {}", manager_id, asset_uuid);
10149 /// # Ok(())
10150 /// # }
10151 /// ```
10152 ///
10153 /// # Related Methods
10154 /// - [`manager_remove_asset`](Self::manager_remove_asset) - Remove manager permissions for an asset
10155 /// - [`get_manager`](Self::get_manager) - Get manager information including current assets
10156 /// - [`get_manager_permissions`](Self::get_manager_permissions) - Get manager's current permissions
10157 /// - [`lock_manager`](Self::lock_manager) - Lock manager account
10158 pub async fn add_asset_to_manager(
10159 &self,
10160 manager_id: i64,
10161 asset_uuid: &str,
10162 ) -> Result<(), Error> {
10163 self.request_empty(
10164 Method::PUT,
10165 &[
10166 "managers",
10167 &manager_id.to_string(),
10168 "assets",
10169 asset_uuid,
10170 "add",
10171 ],
10172 None::<&()>,
10173 )
10174 .await
10175 }
10176
10177 /// Deletes a specific asset assignment.
10178 ///
10179 /// # Arguments
10180 /// * `asset_uuid` - The UUID of the asset
10181 /// * `assignment_id` - The ID of the assignment to delete
10182 ///
10183 /// # Errors
10184 /// Returns an error if:
10185 /// - Authentication fails
10186 /// - The HTTP request fails
10187 /// - The server returns an error status
10188 /// Removes an asset assignment.
10189 ///
10190 /// This method permanently deletes an asset assignment, returning the allocated assets
10191 /// back to the available pool. This operation cannot be undone. If the assignment has
10192 /// already been distributed, this operation may fail.
10193 ///
10194 /// # Arguments
10195 /// * `asset_uuid` - The UUID of the asset containing the assignment
10196 /// * `assignment_id` - The ID of the assignment to delete
10197 ///
10198 /// # Returns
10199 /// Returns `Ok(())` on successful deletion.
10200 ///
10201 /// # Errors
10202 /// Returns an error if:
10203 /// - Authentication fails or insufficient permissions
10204 /// - The asset UUID is invalid or does not exist
10205 /// - The assignment ID is invalid or does not exist
10206 /// - The assignment has already been distributed and cannot be deleted
10207 /// - The assignment is locked and cannot be modified
10208 /// - The HTTP request fails
10209 /// - The server returns an error status
10210 ///
10211 /// # Examples
10212 /// ```no_run
10213 /// # use amp_rs::ApiClient;
10214 /// # #[tokio::main]
10215 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10216 /// let client = ApiClient::new().await?;
10217 ///
10218 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10219 /// let assignment_id = "123";
10220 ///
10221 /// client.delete_asset_assignment(asset_uuid, assignment_id).await?;
10222 /// println!("Successfully deleted assignment {}", assignment_id);
10223 /// # Ok(())
10224 /// # }
10225 /// ```
10226 ///
10227 /// # Related Methods
10228 /// - [`get_asset_assignment`](Self::get_asset_assignment) - Get assignment details before deletion
10229 /// - [`create_asset_assignments`](Self::create_asset_assignments) - Create new assignments
10230 /// - [`edit_asset_assignment`](Self::edit_asset_assignment) - Update assignment instead of deleting
10231 /// - [`lock_asset_assignment`](Self::lock_asset_assignment) - Lock assignment to prevent changes
10232 pub async fn delete_asset_assignment(
10233 &self,
10234 asset_uuid: &str,
10235 assignment_id: &str,
10236 ) -> Result<(), Error> {
10237 self.request_empty(
10238 Method::DELETE,
10239 &["assets", asset_uuid, "assignments", assignment_id, "delete"],
10240 None::<&()>,
10241 )
10242 .await
10243 }
10244
10245 /// Locks a specific asset assignment.
10246 ///
10247 /// # Arguments
10248 /// * `asset_uuid` - The UUID of the asset
10249 /// * `assignment_id` - The ID of the assignment to lock
10250 ///
10251 /// # Errors
10252 /// Returns an error if:
10253 /// - Authentication fails
10254 /// - The HTTP request fails
10255 /// - The server returns an error status
10256 pub async fn lock_asset_assignment(
10257 &self,
10258 asset_uuid: &str,
10259 assignment_id: &str,
10260 ) -> Result<Assignment, Error> {
10261 self.request_json(
10262 Method::PUT,
10263 &["assets", asset_uuid, "assignments", assignment_id, "lock"],
10264 None::<&()>,
10265 )
10266 .await
10267 }
10268
10269 /// Unlocks a specific asset assignment.
10270 ///
10271 /// # Arguments
10272 /// * `asset_uuid` - The UUID of the asset
10273 /// * `assignment_id` - The ID of the assignment to unlock
10274 ///
10275 /// # Errors
10276 /// Returns an error if:
10277 /// - Authentication fails
10278 /// - The HTTP request fails
10279 /// - The server returns an error status
10280 pub async fn unlock_asset_assignment(
10281 &self,
10282 asset_uuid: &str,
10283 assignment_id: &str,
10284 ) -> Result<Assignment, Error> {
10285 self.request_json(
10286 Method::PUT,
10287 &["assets", asset_uuid, "assignments", assignment_id, "unlock"],
10288 None::<&()>,
10289 )
10290 .await
10291 }
10292
10293 /// Adds categories to a registered user.
10294 ///
10295 /// # Arguments
10296 /// * `registered_user_id` - The ID of the registered user
10297 /// * `categories` - A slice of category IDs to add to the user
10298 ///
10299 /// # Errors
10300 /// Returns an error if:
10301 /// - Authentication fails
10302 /// - The HTTP request fails
10303 /// - The server returns an error status
10304 /// - The registered user ID is invalid
10305 /// - Any category ID is invalid
10306 pub async fn add_categories_to_registered_user(
10307 &self,
10308 registered_user_id: i64,
10309 categories: &[i64],
10310 ) -> Result<(), Error> {
10311 let request_body = CategoriesRequest {
10312 categories: categories.to_vec(),
10313 };
10314
10315 self.request_empty(
10316 Method::PUT,
10317 &[
10318 "registered_users",
10319 ®istered_user_id.to_string(),
10320 "categories",
10321 "add",
10322 ],
10323 Some(request_body),
10324 )
10325 .await
10326 }
10327
10328 /// Removes categories from a registered user
10329 ///
10330 /// # Arguments
10331 /// * `registered_user_id` - The ID of the registered user
10332 /// * `categories` - A slice of category IDs to remove from the user
10333 ///
10334 /// # Returns
10335 /// Returns `Ok(())` if the categories are successfully removed, or an error if:
10336 /// - Authentication fails
10337 /// - The HTTP request fails
10338 /// - The server returns an error status
10339 /// - The registered user ID is invalid
10340 /// - Any category ID is not associated with the user
10341 pub async fn remove_categories_from_registered_user(
10342 &self,
10343 registered_user_id: i64,
10344 categories: &[i64],
10345 ) -> Result<(), Error> {
10346 let request_body = CategoriesRequest {
10347 categories: categories.to_vec(),
10348 };
10349
10350 self.request_empty(
10351 Method::PUT,
10352 &[
10353 "registered_users",
10354 ®istered_user_id.to_string(),
10355 "categories",
10356 "delete",
10357 ],
10358 Some(request_body),
10359 )
10360 .await
10361 }
10362
10363 /// Distributes assets to multiple users through a comprehensive workflow
10364 ///
10365 /// This method orchestrates the complete asset distribution process:
10366 /// 1. Validates input parameters (asset UUID format, assignments structure)
10367 /// 2. Verifies `ElementsRpc` connection and signer interface availability
10368 /// 3. Authenticates with the AMP API using the client's token
10369 /// 4. Creates a distribution request via the AMP API
10370 /// 5. Constructs and signs the blockchain transaction using the provided signer
10371 /// 6. Broadcasts the transaction to the Elements network
10372 /// 7. Waits for blockchain confirmations (2 confirmations minimum)
10373 /// 8. Confirms the distribution with the AMP API
10374 ///
10375 /// # Arguments
10376 /// * `asset_uuid` - The UUID of the asset to distribute (must be valid UUID format)
10377 /// * `assignments` - Vector of assignments specifying `user_id`, address, and amount
10378 /// * `node_rpc` - `ElementsRpc` client for blockchain operations
10379 /// * `signer` - Signer implementation for transaction signing
10380 ///
10381 /// # Returns
10382 /// Returns `Ok(())` if the distribution completes successfully, or an `AmpError` if:
10383 /// - Input validation fails (invalid UUID format, empty assignments, etc.)
10384 /// - `ElementsRpc` connection cannot be established
10385 /// - Signer interface is not available
10386 /// - Authentication with AMP API fails
10387 /// - Distribution creation fails
10388 /// - Transaction construction or signing fails
10389 /// - Blockchain broadcasting fails
10390 /// - Confirmation timeout occurs
10391 /// - Distribution confirmation with AMP API fails
10392 ///
10393 /// # Examples
10394 /// ```no_run
10395 /// # use amp_rs::{ApiClient, ElementsRpc, AmpError};
10396 /// # use amp_rs::model::AssetDistributionAssignment;
10397 /// # use amp_rs::signer::{Signer, LwkSoftwareSigner};
10398 /// # #[tokio::main]
10399 /// # async fn main() -> Result<(), AmpError> {
10400 /// let client = ApiClient::new().await?;
10401 /// let elements_rpc = ElementsRpc::from_env()?;
10402 /// let (_, signer) = LwkSoftwareSigner::generate_new()?;
10403 ///
10404 /// let assignments = vec![
10405 /// AssetDistributionAssignment {
10406 /// user_id: "user123".to_string(),
10407 /// address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
10408 /// amount: 100.0,
10409 /// },
10410 /// ];
10411 ///
10412 /// client.distribute_asset(
10413 /// "550e8400-e29b-41d4-a716-446655440000",
10414 /// assignments,
10415 /// &elements_rpc,
10416 /// "wallet_name",
10417 /// &signer
10418 /// ).await?;
10419 /// # Ok(())
10420 /// # }
10421 /// ```
10422 ///
10423 /// # Requirements
10424 /// This method implements requirements:
10425 /// - 1.1: Single method for complete distribution workflow
10426 /// - 2.2: Assignment details validation
10427 /// - 2.4: Input validation for all parameters
10428 /// - 5.1: Comprehensive error handling with context
10429 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
10430 pub async fn distribute_asset(
10431 &self,
10432 asset_uuid: &str,
10433 assignments: Vec<AssetDistributionAssignment>,
10434 node_rpc: &ElementsRpc,
10435 wallet_name: &str,
10436 signer: &dyn Signer,
10437 ) -> Result<(), AmpError> {
10438 let distribution_span = tracing::info_span!(
10439 "distribute_asset",
10440 asset_uuid = %asset_uuid,
10441 assignment_count = assignments.len()
10442 );
10443 let _enter = distribution_span.enter();
10444
10445 tracing::info!(
10446 "Starting asset distribution workflow for asset: {} with {} assignments",
10447 asset_uuid,
10448 assignments.len()
10449 );
10450
10451 // Step 1: Input validation - asset_uuid format
10452 tracing::debug!("Step 1: Validating asset UUID format");
10453 Self::validate_asset_uuid(asset_uuid).map_err(|e| {
10454 let error = AmpError::validation(format!("Invalid asset UUID: {e}"));
10455 tracing::error!("Asset UUID validation failed: {}", e);
10456 error.with_context("Step 1: Asset UUID validation")
10457 })?;
10458 tracing::debug!("Asset UUID validation passed");
10459
10460 // Step 2: Input validation - assignments data structure
10461 tracing::debug!("Step 2: Validating {} assignments", assignments.len());
10462 Self::validate_assignments(&assignments).map_err(|e| {
10463 let error = AmpError::validation(format!("Invalid assignments: {e}"));
10464 tracing::error!("Assignments validation failed: {}", e);
10465 error.with_context("Step 2: Assignments validation")
10466 })?;
10467 tracing::debug!("Assignments validation passed");
10468
10469 // Step 3: Check ElementsRpc connection availability
10470 tracing::debug!("Step 3: Validating Elements RPC connection");
10471 self.validate_elements_rpc_connection(node_rpc)
10472 .await
10473 .map_err(|e| {
10474 let error = AmpError::rpc(format!("ElementsRpc connection validation failed: {e}"));
10475 tracing::error!("Elements RPC connection validation failed: {}", e);
10476 error.with_context("Step 3: Elements RPC connection validation")
10477 })?;
10478 tracing::debug!("Elements RPC connection validation passed");
10479
10480 // Step 4: Check signer interface availability
10481 tracing::debug!("Step 4: Validating signer interface");
10482 self.validate_signer_interface(signer).await.map_err(|e| {
10483 let error = AmpError::validation(format!("Signer interface validation failed: {e}"));
10484 tracing::error!("Signer interface validation failed: {}", e);
10485 error.with_context("Step 4: Signer interface validation")
10486 })?;
10487 tracing::debug!("Signer interface validation passed");
10488
10489 tracing::info!("✓ All input validations completed successfully");
10490
10491 // Step 5: Authenticate with AMP API using existing TokenManager
10492 tracing::debug!("Step 5: Authenticating with AMP API");
10493 let _token = self.token_strategy.get_token().await.map_err(|e| {
10494 tracing::error!("AMP API authentication failed: {}", e);
10495 let amp_error = AmpError::Existing(e);
10496 if amp_error.is_retryable() {
10497 if let Some(instructions) = amp_error.retry_instructions() {
10498 tracing::warn!("Retry instructions: {}", instructions);
10499 }
10500 }
10501 amp_error.with_context("Step 5: AMP API authentication")
10502 })?;
10503 tracing::info!("✓ Successfully authenticated with AMP API");
10504
10505 // Step 6: Create distribution request and parse response data
10506 tracing::debug!(
10507 "Step 6: Creating distribution request with {} assignments",
10508 assignments.len()
10509 );
10510 let distribution_response = self
10511 .create_distribution(asset_uuid, assignments)
10512 .await
10513 .map_err(|e| {
10514 tracing::error!("Distribution creation failed: {}", e);
10515 if e.is_retryable() {
10516 if let Some(instructions) = e.retry_instructions() {
10517 tracing::warn!("Retry instructions: {}", instructions);
10518 }
10519 }
10520 e.with_context("Step 6: Distribution creation")
10521 })?;
10522
10523 tracing::info!(
10524 "✓ Distribution created successfully: {} with asset_id: {}",
10525 distribution_response.distribution_uuid,
10526 distribution_response.asset_id
10527 );
10528
10529 // Step 7: Verify Elements node status and execute transaction workflow
10530 tracing::debug!("Step 7: Verifying Elements node status");
10531 let (network_info, blockchain_info) = node_rpc.get_node_status().await.map_err(|e| {
10532 tracing::error!("Elements node status verification failed: {}", e);
10533 if e.is_retryable() {
10534 if let Some(instructions) = e.retry_instructions() {
10535 tracing::warn!("Retry instructions: {}", instructions);
10536 }
10537 }
10538 e.with_context("Step 7: Elements node status verification")
10539 })?;
10540
10541 tracing::info!(
10542 "✓ Elements node verified - chain: {}, blocks: {}, connections: {}",
10543 blockchain_info.chain,
10544 blockchain_info.blocks,
10545 network_info.connections
10546 );
10547
10548 // Step 8: Send distribution transaction using Elements' sendmany
10549 tracing::debug!("Step 8: Sending distribution transaction using Elements sendmany");
10550
10551 // Create asset amounts map for sendmany (all outputs use the same asset)
10552 let mut asset_amounts = std::collections::HashMap::new();
10553 for address in distribution_response.map_address_amount.keys() {
10554 asset_amounts.insert(address.clone(), distribution_response.asset_id.clone());
10555 }
10556
10557 tracing::info!(
10558 "Using sendmany for {} outputs with asset {}",
10559 distribution_response.map_address_amount.len(),
10560 distribution_response.asset_id
10561 );
10562
10563 // Use Elements' sendmany which properly handles confidential transactions
10564 let txid = node_rpc
10565 .sendmany(
10566 wallet_name,
10567 distribution_response.map_address_amount.clone(),
10568 asset_amounts,
10569 Some(0), // min_conf: 0 to include unconfirmed UTXOs (matches Python implementation)
10570 Some("AMP asset distribution"), // comment
10571 None, // subtract_fee_from: let Elements handle fees automatically
10572 Some(false), // replaceable: false for final transactions
10573 Some(1), // conf_target: 1 block for faster confirmation
10574 Some("UNSET"), // estimate_mode: let Elements choose
10575 )
10576 .await
10577 .map_err(|e| {
10578 tracing::error!("Sendmany transaction failed: {}", e);
10579 if e.is_retryable() {
10580 if let Some(instructions) = e.retry_instructions() {
10581 tracing::warn!("Retry instructions: {}", instructions);
10582 }
10583 }
10584 e.with_context("Step 8: Sendmany transaction")
10585 })?;
10586
10587 tracing::info!("✓ Transaction sent successfully with ID: {}", txid);
10588
10589 // Step 9: Wait for confirmations
10590 tracing::debug!("Step 9: Waiting for blockchain confirmations (minimum 2 confirmations, 10-minute timeout)");
10591 let confirmation_start = std::time::Instant::now();
10592 let tx_detail = node_rpc.wait_for_confirmations(&txid, Some(2), Some(10)).await
10593 .map_err(|e| {
10594 let elapsed = confirmation_start.elapsed();
10595 tracing::error!(
10596 "Confirmation waiting failed after {:?}: {}",
10597 elapsed,
10598 e
10599 );
10600
10601 if let AmpError::Timeout(_) = &e {
10602 tracing::warn!(
10603 "Confirmation timeout - transaction {} may still be pending. \
10604 Use this txid to manually confirm the distribution if it gets confirmed later.",
10605 txid
10606 );
10607 let timeout_error = AmpError::timeout(format!(
10608 "Confirmation timeout for txid: {txid}. Use this txid to manually confirm the distribution."
10609 ));
10610 timeout_error.with_context("Step 9: Confirmation waiting")
10611 } else {
10612 if e.is_retryable() {
10613 if let Some(instructions) = e.retry_instructions() {
10614 tracing::warn!("Retry instructions: {}", instructions);
10615 }
10616 }
10617 e.with_context(format!("Step 9: Confirmation waiting for txid: {txid}"))
10618 }
10619 })?;
10620
10621 let confirmation_duration = confirmation_start.elapsed();
10622 tracing::info!(
10623 "✓ Transaction confirmed with {} confirmations at block height: {:?} (took {:?})",
10624 tx_detail.confirmations,
10625 tx_detail.blockheight,
10626 confirmation_duration
10627 );
10628
10629 // Step 10: Collect change data for confirmation
10630 tracing::debug!("Step 10: Collecting change data for distribution confirmation");
10631 let change_data = node_rpc
10632 .collect_change_data(
10633 &distribution_response.asset_id,
10634 &txid,
10635 node_rpc,
10636 wallet_name,
10637 )
10638 .await
10639 .map_err(|e| {
10640 tracing::error!("Change data collection failed: {}", e);
10641 if e.is_retryable() {
10642 if let Some(instructions) = e.retry_instructions() {
10643 tracing::warn!("Retry instructions: {}", instructions);
10644 }
10645 }
10646 e.with_context("Step 10: Change data collection")
10647 })?;
10648
10649 tracing::info!("✓ Collected {} change UTXOs", change_data.len());
10650 if !change_data.is_empty() {
10651 tracing::debug!("Change UTXOs: {:?}", change_data);
10652 }
10653
10654 // Step 11: Submit final confirmation to AMP API
10655 tracing::debug!("Step 11: Submitting final confirmation to AMP API");
10656
10657 // Extract the details field from the transaction (matching Python implementation)
10658 // Python: details = rpc.call('gettransaction', txid).get('details')
10659 let transaction_details = tx_detail.details.unwrap_or_else(Vec::new);
10660 tracing::debug!(
10661 "Transaction details for confirmation: {:?}",
10662 transaction_details
10663 );
10664
10665 let amp_tx_data = crate::model::AmpTxData {
10666 details: serde_json::Value::Array(transaction_details),
10667 txid: txid.clone(),
10668 };
10669
10670 // Log the exact payload being sent to AMP for debugging
10671 tracing::info!("Sending confirmation payload to AMP:");
10672 tracing::info!(" tx_data.txid: {}", amp_tx_data.txid);
10673 tracing::info!(" tx_data.details: {:?}", amp_tx_data.details);
10674 tracing::info!(" change_data: {} UTXOs", change_data.len());
10675
10676 let confirmation_request = crate::model::ConfirmDistributionRequest {
10677 tx_data: amp_tx_data.clone(),
10678 change_data: change_data.clone(),
10679 };
10680
10681 if let Ok(payload_json) = serde_json::to_string_pretty(&confirmation_request) {
10682 tracing::debug!("Full confirmation payload: {}", payload_json);
10683 }
10684
10685 self.confirm_distribution(
10686 asset_uuid,
10687 &distribution_response.distribution_uuid,
10688 amp_tx_data,
10689 change_data,
10690 )
10691 .await
10692 .map_err(|e| {
10693 tracing::error!("Distribution confirmation failed: {}", e);
10694
10695 // For confirmation failures, always provide retry instructions with txid
10696 let confirmation_error = AmpError::api(format!(
10697 "Failed to confirm distribution {}: {}. \
10698 IMPORTANT: Transaction {} was successful on blockchain. \
10699 Use this txid to manually retry confirmation.",
10700 distribution_response.distribution_uuid, e, txid
10701 ));
10702
10703 if e.is_retryable() {
10704 if let Some(instructions) = e.retry_instructions() {
10705 tracing::warn!("Retry instructions: {}", instructions);
10706 }
10707 }
10708
10709 confirmation_error.with_context("Step 11: Distribution confirmation")
10710 })?;
10711
10712 tracing::info!(
10713 "🎉 Asset distribution completed successfully for asset: {} with transaction: {}",
10714 asset_uuid,
10715 txid
10716 );
10717
10718 Ok(())
10719 }
10720
10721 /// Validates the asset UUID format
10722 ///
10723 /// Ensures the asset UUID follows the standard UUID format (8-4-4-4-12 hexadecimal digits)
10724 ///
10725 /// # Arguments
10726 /// * `asset_uuid` - The asset UUID string to validate
10727 ///
10728 /// # Returns
10729 /// Returns `Ok(())` if valid, or an error describing the validation failure
10730 ///
10731 /// # Errors
10732 /// - Empty or whitespace-only UUID
10733 /// - Invalid UUID format (not matching standard UUID pattern)
10734 /// - UUID contains invalid characters
10735 fn validate_asset_uuid(asset_uuid: &str) -> Result<(), String> {
10736 if asset_uuid.trim().is_empty() {
10737 return Err("Asset UUID cannot be empty".to_string());
10738 }
10739
10740 // Basic UUID format validation (8-4-4-4-12 pattern)
10741 // Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
10742 let parts: Vec<&str> = asset_uuid.split('-').collect();
10743 if parts.len() != 5 {
10744 return Err(format!(
10745 "Asset UUID '{asset_uuid}' does not have 5 parts separated by hyphens"
10746 ));
10747 }
10748
10749 // Check each part has the correct length and contains only hex characters
10750 let expected_lengths = [8, 4, 4, 4, 12];
10751 for (i, (part, &expected_len)) in parts.iter().zip(expected_lengths.iter()).enumerate() {
10752 if part.len() != expected_len {
10753 return Err(format!(
10754 "Asset UUID part {} has length {} but expected {}",
10755 i + 1,
10756 part.len(),
10757 expected_len
10758 ));
10759 }
10760
10761 // Check if all characters are valid hexadecimal
10762 if !part.chars().all(|c| c.is_ascii_hexdigit()) {
10763 return Err(format!(
10764 "Asset UUID part {} contains non-hexadecimal characters: '{}'",
10765 i + 1,
10766 part
10767 ));
10768 }
10769 }
10770
10771 tracing::debug!("Asset UUID validation passed: {}", asset_uuid);
10772 Ok(())
10773 }
10774
10775 /// Validates the assignments data structure
10776 ///
10777 /// Ensures assignments vector is not empty and each assignment has valid data
10778 ///
10779 /// # Arguments
10780 /// * `assignments` - Vector of assignments to validate
10781 ///
10782 /// # Returns
10783 /// Returns `Ok(())` if valid, or an error describing the validation failure
10784 ///
10785 /// # Errors
10786 /// - Empty assignments vector
10787 /// - Assignment with empty `user_id`
10788 /// - Assignment with empty address
10789 /// - Assignment with non-positive amount
10790 /// - Assignment with invalid address format
10791 #[allow(clippy::cognitive_complexity)]
10792 fn validate_assignments(assignments: &[AssetDistributionAssignment]) -> Result<(), String> {
10793 tracing::debug!("Validating {} assignments", assignments.len());
10794
10795 if assignments.is_empty() {
10796 tracing::error!("Assignments validation failed: empty assignments vector");
10797 return Err("Assignments vector cannot be empty".to_string());
10798 }
10799
10800 let mut total_amount = 0.0;
10801 let mut unique_addresses = std::collections::HashSet::new();
10802 let mut unique_users = std::collections::HashSet::new();
10803
10804 for (index, assignment) in assignments.iter().enumerate() {
10805 tracing::trace!(
10806 "Validating assignment {}: user_id={}, address={}, amount={}",
10807 index,
10808 assignment.user_id,
10809 assignment.address,
10810 assignment.amount
10811 );
10812
10813 // Validate user_id
10814 if assignment.user_id.trim().is_empty() {
10815 tracing::error!("Assignment {} validation failed: empty user_id", index);
10816 return Err(format!("Assignment {index} has empty user_id"));
10817 }
10818
10819 // Validate address
10820 if assignment.address.trim().is_empty() {
10821 tracing::error!("Assignment {} validation failed: empty address", index);
10822 return Err(format!("Assignment {index} has empty address"));
10823 }
10824
10825 // Basic address format validation (should start with appropriate prefix for Liquid)
10826 if !assignment.address.starts_with("lq")
10827 && !assignment.address.starts_with("vj")
10828 && !assignment.address.starts_with("VJ")
10829 && !assignment.address.starts_with("VT")
10830 {
10831 tracing::error!(
10832 "Assignment {} validation failed: invalid address format '{}' (should start with 'lq', 'vj', 'VJ', or 'VT')",
10833 index, assignment.address
10834 );
10835 return Err(format!(
10836 "Assignment {} has invalid address format: '{}' (should start with 'lq', 'vj', 'VJ', or 'VT')",
10837 index, assignment.address
10838 ));
10839 }
10840
10841 // Validate amount
10842 if assignment.amount <= 0.0 {
10843 tracing::error!(
10844 "Assignment {} validation failed: non-positive amount {}",
10845 index,
10846 assignment.amount
10847 );
10848 return Err(format!(
10849 "Assignment {} has non-positive amount: {}",
10850 index, assignment.amount
10851 ));
10852 }
10853
10854 // Check for reasonable amount limits (prevent overflow issues)
10855 if assignment.amount > 21_000_000.0 {
10856 tracing::error!(
10857 "Assignment {} validation failed: unreasonably large amount {} (max: 21,000,000)",
10858 index, assignment.amount
10859 );
10860 return Err(format!(
10861 "Assignment {} has unreasonably large amount: {} (max: 21,000,000)",
10862 index, assignment.amount
10863 ));
10864 }
10865
10866 // Check for precision issues (more than 8 decimal places)
10867 let amount_str = format!("{:.8}", assignment.amount);
10868 if amount_str.len() > 20 {
10869 // Reasonable length check
10870 tracing::warn!(
10871 "Assignment {} has high precision amount: {} - may cause precision issues",
10872 index,
10873 assignment.amount
10874 );
10875 }
10876
10877 // Track duplicates for warnings
10878 if !unique_addresses.insert(&assignment.address) {
10879 tracing::warn!(
10880 "Assignment {} uses duplicate address: {} (this may be intentional)",
10881 index,
10882 assignment.address
10883 );
10884 }
10885
10886 if !unique_users.insert(&assignment.user_id) {
10887 tracing::warn!(
10888 "Assignment {} uses duplicate user_id: {} (this may be intentional)",
10889 index,
10890 assignment.user_id
10891 );
10892 }
10893
10894 total_amount += assignment.amount;
10895 }
10896
10897 tracing::debug!(
10898 "Assignments validation passed - {} assignments, total amount: {}, unique addresses: {}, unique users: {}",
10899 assignments.len(),
10900 total_amount,
10901 unique_addresses.len(),
10902 unique_users.len()
10903 );
10904
10905 if total_amount > 100_000_000.0 {
10906 tracing::warn!(
10907 "Total distribution amount is very large: {} - ensure this is intentional",
10908 total_amount
10909 );
10910 }
10911
10912 Ok(())
10913 }
10914
10915 /// Validates `ElementsRpc` connection availability
10916 ///
10917 /// Attempts to connect to the Elements node and verify basic functionality
10918 ///
10919 /// # Arguments
10920 /// * `node_rpc` - `ElementsRpc` client to validate
10921 ///
10922 /// # Returns
10923 /// Returns `Ok(())` if connection is valid, or an error describing the failure
10924 ///
10925 /// # Errors
10926 /// - Cannot connect to Elements node
10927 /// - Node is not synchronized
10928 /// - Node version is incompatible
10929 /// - RPC authentication fails
10930 #[allow(clippy::cognitive_complexity)]
10931 async fn validate_elements_rpc_connection(&self, node_rpc: &ElementsRpc) -> Result<(), String> {
10932 tracing::debug!("Validating Elements RPC connection");
10933
10934 // Test basic connectivity by getting network info
10935 tracing::trace!("Testing Elements RPC connectivity with getnetworkinfo");
10936 let network_info = node_rpc.get_network_info().await.map_err(|e| {
10937 tracing::error!("Failed to get network info from Elements node: {}", e);
10938 format!("Failed to get network info: {e}")
10939 })?;
10940
10941 tracing::debug!(
10942 "Network info retrieved - version: {}, connections: {}, network_active: {}",
10943 network_info.version,
10944 network_info.connections,
10945 network_info.networkactive
10946 );
10947
10948 // Check if network is active
10949 if !network_info.networkactive {
10950 tracing::error!("Elements node network is not active");
10951 return Err("Elements node network is not active".to_string());
10952 }
10953
10954 // Verify we have active connections (for non-regtest environments)
10955 if network_info.connections == 0 {
10956 tracing::warn!("Elements node has no peer connections (may be regtest environment)");
10957 } else {
10958 tracing::debug!(
10959 "Elements node has {} peer connections",
10960 network_info.connections
10961 );
10962 }
10963
10964 // Test blockchain info to ensure node is operational
10965 tracing::trace!("Testing Elements RPC with getblockchaininfo");
10966 let blockchain_info = node_rpc.get_blockchain_info().await.map_err(|e| {
10967 tracing::error!("Failed to get blockchain info from Elements node: {}", e);
10968 format!("Failed to get blockchain info: {e}")
10969 })?;
10970
10971 let sync_progress = blockchain_info.verificationprogress.unwrap_or(1.0) * 100.0;
10972 tracing::debug!(
10973 "Blockchain info retrieved - chain: {}, blocks: {}, sync_progress: {:.2}%",
10974 blockchain_info.chain,
10975 blockchain_info.blocks,
10976 sync_progress
10977 );
10978
10979 // Check if node is still in initial block download
10980 if blockchain_info.initialblockdownload.unwrap_or(false) {
10981 tracing::error!(
10982 "Elements node is still in initial block download (sync progress: {:.2}%)",
10983 sync_progress
10984 );
10985 return Err(format!(
10986 "Elements node is still in initial block download (sync progress: {sync_progress:.2}%)"
10987 ));
10988 }
10989
10990 // Check sync progress
10991 if blockchain_info.verificationprogress.unwrap_or(1.0) < 0.99 {
10992 tracing::warn!(
10993 "Elements node may not be fully synced (sync progress: {:.2}%)",
10994 sync_progress
10995 );
10996 }
10997
10998 // Check for warnings
10999 if !network_info.warnings.is_empty() {
11000 tracing::warn!("Elements node network warnings: {}", network_info.warnings);
11001 }
11002
11003 if let Some(warnings) = &blockchain_info.warnings {
11004 if !warnings.is_empty() {
11005 tracing::warn!("Elements node blockchain warnings: {}", warnings);
11006 }
11007 }
11008
11009 tracing::debug!(
11010 "ElementsRpc connection validation passed - chain: {}, blocks: {}, connections: {}, sync: {:.2}%",
11011 blockchain_info.chain,
11012 blockchain_info.blocks,
11013 network_info.connections,
11014 sync_progress
11015 );
11016
11017 Ok(())
11018 }
11019
11020 /// Validates signer interface availability
11021 ///
11022 /// Tests the signer interface with a dummy transaction to ensure it's functional
11023 ///
11024 /// # Arguments
11025 /// * `signer` - Signer implementation to validate
11026 ///
11027 /// # Returns
11028 /// Returns `Ok(())` if signer is functional, or an error describing the failure
11029 ///
11030 /// # Errors
11031 /// - Signer interface is not responsive
11032 /// - Signer fails basic functionality test
11033 #[allow(clippy::cognitive_complexity)]
11034 async fn validate_signer_interface(&self, signer: &dyn Signer) -> Result<(), String> {
11035 tracing::debug!("Validating signer interface");
11036
11037 // Test signer with a minimal dummy transaction hex
11038 // This is a minimal Elements transaction structure that should parse but not be valid for signing
11039 let dummy_tx = "0200000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
11040
11041 tracing::trace!("Testing signer interface with dummy transaction");
11042
11043 // Attempt to sign the dummy transaction - we expect this to fail with a specific error
11044 // but the signer should be responsive and not panic
11045 let validation_start = std::time::Instant::now();
11046 match signer.sign_transaction(dummy_tx).await {
11047 Ok(signed_tx) => {
11048 // Unexpected success with dummy transaction - this might indicate an issue
11049 tracing::warn!(
11050 "Signer unexpectedly succeeded with dummy transaction (returned: {} chars)",
11051 signed_tx.len()
11052 );
11053 tracing::debug!("Signer validation passed despite unexpected success");
11054 }
11055 Err(SignerError::InvalidTransaction(msg)) => {
11056 // Expected error - signer is working and correctly identified invalid transaction
11057 tracing::debug!(
11058 "Signer interface validation passed - correctly rejected dummy transaction: {}",
11059 msg
11060 );
11061 }
11062 Err(SignerError::HexParse(msg)) => {
11063 // Also acceptable - signer is working and correctly identified parsing issue
11064 tracing::debug!(
11065 "Signer interface validation passed - correctly identified hex parsing issue: {}",
11066 msg
11067 );
11068 }
11069 Err(SignerError::Lwk(msg)) => {
11070 // LWK-specific errors might be acceptable depending on the message
11071 if msg.contains("invalid") || msg.contains("parse") || msg.contains("decode") {
11072 tracing::debug!(
11073 "Signer interface validation passed - LWK correctly identified invalid transaction: {}",
11074 msg
11075 );
11076 } else {
11077 tracing::error!("Signer interface test failed with LWK error: {}", msg);
11078 return Err(format!(
11079 "Signer interface test failed with LWK error: {msg}"
11080 ));
11081 }
11082 }
11083 Err(e) => {
11084 // Other errors might indicate signer interface issues
11085 tracing::error!("Signer interface test failed: {}", e);
11086 return Err(format!("Signer interface test failed: {e}"));
11087 }
11088 }
11089
11090 let validation_duration = validation_start.elapsed();
11091 tracing::debug!(
11092 "Signer interface validation completed in {:?}",
11093 validation_duration
11094 );
11095
11096 // Warn if signer is very slow (might indicate performance issues)
11097 if validation_duration > std::time::Duration::from_secs(5) {
11098 tracing::warn!(
11099 "Signer interface validation took {:?} - this may indicate performance issues",
11100 validation_duration
11101 );
11102 }
11103
11104 Ok(())
11105 }
11106}
11107
11108fn get_amp_api_base_url() -> Result<Url, Error> {
11109 let url_str = env::var("AMP_API_BASE_URL")
11110 .unwrap_or_else(|_| "https://amp-test.blockstream.com/api".to_string());
11111 Url::parse(&url_str).map_err(Error::from)
11112}
11113
11114/// Creates a token strategy based on automatic environment detection
11115///
11116/// This function detects the current environment and creates the appropriate strategy:
11117/// - Mock strategy for mock environments (isolated, no persistence)
11118/// - Live strategy for live environments (full token management)
11119///
11120/// # Arguments
11121/// * `mock_token` - Optional token to use for mock environments
11122///
11123/// # Errors
11124/// Returns an error if strategy creation fails
11125pub async fn create_auto_token_strategy(
11126 mock_token: Option<String>,
11127) -> Result<Box<dyn TokenStrategy>, Error> {
11128 TokenEnvironment::create_auto_strategy(mock_token).await
11129}
11130
11131/// Creates a mock token strategy with the specified token
11132///
11133/// # Arguments
11134/// * `token` - The mock token to use
11135#[must_use]
11136pub fn create_mock_token_strategy(token: String) -> Box<dyn TokenStrategy> {
11137 Box::new(MockTokenStrategy::new(token))
11138}
11139
11140/// Creates a live token strategy with default configuration
11141///
11142/// # Errors
11143/// Returns an error if the `TokenManager` cannot be initialized
11144pub async fn create_live_token_strategy() -> Result<Box<dyn TokenStrategy>, Error> {
11145 let strategy = LiveTokenStrategy::new().await?;
11146 Ok(Box::new(strategy))
11147}
11148
11149/// Creates a token strategy for the specified environment
11150///
11151/// # Arguments
11152/// * `environment` - The target environment
11153/// * `mock_token` - Optional token to use for mock environments
11154///
11155/// # Errors
11156/// Returns an error if strategy creation fails
11157pub async fn create_token_strategy_for_environment(
11158 environment: TokenEnvironment,
11159 mock_token: Option<String>,
11160) -> Result<Box<dyn TokenStrategy>, Error> {
11161 environment.create_strategy(mock_token).await
11162}
11163
11164#[cfg(test)]
11165mod tests {
11166 use super::*;
11167 use crate::signer::LwkSoftwareSigner;
11168 use tokio;
11169
11170 #[tokio::test]
11171 async fn test_mock_token_strategy_basic_functionality() {
11172 let mock_token = "mock_token_12_345".to_string();
11173 let strategy = MockTokenStrategy::new(mock_token.clone());
11174
11175 // Test get_token returns the mock token
11176 let result = strategy.get_token().await;
11177 assert!(result.is_ok());
11178 assert_eq!(result.unwrap(), mock_token);
11179
11180 // Test strategy type identification
11181 assert_eq!(strategy.strategy_type(), "mock");
11182
11183 // Test persistence is disabled
11184 assert!(!strategy.should_persist());
11185
11186 // Test clear_token is a no-op (should not fail)
11187 let clear_result = strategy.clear_token().await;
11188 assert!(clear_result.is_ok());
11189
11190 // Verify token is still available after clear (since it's a no-op for mock)
11191 let token_after_clear = strategy.get_token().await;
11192 assert!(token_after_clear.is_ok());
11193 assert_eq!(token_after_clear.unwrap(), mock_token);
11194 }
11195
11196 #[tokio::test]
11197 async fn test_mock_token_strategy_isolation() {
11198 let token1 = "token_instance_1".to_string();
11199 let token2 = "token_instance_2".to_string();
11200
11201 let strategy1 = MockTokenStrategy::new(token1.clone());
11202 let strategy2 = MockTokenStrategy::new(token2.clone());
11203
11204 // Test that different instances are isolated
11205 let result1 = strategy1.get_token().await.unwrap();
11206 let result2 = strategy2.get_token().await.unwrap();
11207
11208 assert_eq!(result1, token1);
11209 assert_eq!(result2, token2);
11210 assert_ne!(result1, result2);
11211
11212 // Test that operations on one don't affect the other
11213 let _ = strategy1.clear_token().await;
11214 let result2_after_clear = strategy2.get_token().await.unwrap();
11215 assert_eq!(result2_after_clear, token2);
11216 }
11217
11218 #[tokio::test]
11219 async fn test_live_token_strategy_creation() {
11220 // Test creating a live strategy with global instance
11221 let strategy_result = LiveTokenStrategy::new().await;
11222 assert!(strategy_result.is_ok());
11223
11224 let strategy = strategy_result.unwrap();
11225 assert_eq!(strategy.strategy_type(), "live");
11226 assert!(strategy.should_persist());
11227 }
11228
11229 #[tokio::test]
11230 async fn test_live_token_strategy_with_custom_manager() {
11231 // Create a custom token manager for testing
11232 let config = RetryConfig::for_tests();
11233 let base_url = Url::parse("http://localhost:8080").unwrap();
11234 let mock_token = "test_live_token".to_string();
11235
11236 let token_manager =
11237 Arc::new(TokenManager::with_mock_token(config, base_url, mock_token.clone()).unwrap());
11238
11239 let strategy = LiveTokenStrategy::with_token_manager(token_manager);
11240
11241 // Test strategy properties
11242 assert_eq!(strategy.strategy_type(), "live");
11243 assert!(strategy.should_persist());
11244
11245 // Test token retrieval
11246 let token_result = strategy.get_token().await;
11247 assert!(token_result.is_ok());
11248 assert_eq!(token_result.unwrap(), mock_token);
11249 }
11250
11251 #[tokio::test]
11252 async fn test_live_token_strategy_clear_token() {
11253 // Create a live strategy with a mock token manager
11254 let config = RetryConfig::for_tests();
11255 let base_url = Url::parse("http://localhost:8080").unwrap();
11256 let mock_token = "test_clear_token".to_string();
11257
11258 let token_manager =
11259 Arc::new(TokenManager::with_mock_token(config, base_url, mock_token.clone()).unwrap());
11260
11261 let strategy = LiveTokenStrategy::with_token_manager(token_manager);
11262
11263 // Verify token is available initially
11264 let initial_token = strategy.get_token().await;
11265 assert!(initial_token.is_ok());
11266 assert_eq!(initial_token.unwrap(), mock_token);
11267
11268 // Clear the token
11269 let clear_result = strategy.clear_token().await;
11270 assert!(clear_result.is_ok());
11271
11272 // Note: After clearing, the TokenManager would try to obtain a new token
11273 // In a real scenario, this would fail without proper credentials
11274 // But our mock token manager will still return the same token
11275 }
11276
11277 #[tokio::test]
11278 async fn test_strategy_type_identification() {
11279 let mock_strategy = MockTokenStrategy::new("test_token".to_string());
11280 let live_strategy = LiveTokenStrategy::new().await.unwrap();
11281
11282 // Test that we can identify strategy types for debugging
11283 assert_eq!(mock_strategy.strategy_type(), "mock");
11284 assert_eq!(live_strategy.strategy_type(), "live");
11285
11286 // Test persistence settings
11287 assert!(!mock_strategy.should_persist());
11288 assert!(live_strategy.should_persist());
11289 }
11290
11291 #[tokio::test]
11292 async fn test_strategy_debug_formatting() {
11293 let mock_strategy = MockTokenStrategy::new("debug_test_token".to_string());
11294 let debug_output = format!("{mock_strategy:?}");
11295
11296 // Verify debug output contains expected information
11297 assert!(debug_output.contains("MockTokenStrategy"));
11298 assert!(debug_output.contains("debug_test_token"));
11299 }
11300
11301 // Environment Detection Tests
11302
11303 #[test]
11304 fn test_token_environment_detect_live_via_amp_tests() {
11305 // Set up environment for live test detection
11306 env::set_var("AMP_TESTS", "live");
11307 env::set_var("AMP_USERNAME", "real_user");
11308 env::set_var("AMP_PASSWORD", "real_pass");
11309 env::remove_var("AMP_API_BASE_URL");
11310
11311 let environment = TokenEnvironment::detect();
11312 assert_eq!(environment, TokenEnvironment::Live);
11313
11314 // Clean up
11315 env::remove_var("AMP_TESTS");
11316 env::remove_var("AMP_USERNAME");
11317 env::remove_var("AMP_PASSWORD");
11318 }
11319
11320 #[test]
11321 fn test_token_environment_detect_mock_via_credentials() {
11322 // Set up environment for mock detection via username
11323 env::remove_var("AMP_TESTS");
11324 env::set_var("AMP_USERNAME", "mock_user");
11325 env::set_var("AMP_PASSWORD", "real_pass");
11326 env::remove_var("AMP_API_BASE_URL");
11327
11328 let environment = TokenEnvironment::detect();
11329 assert_eq!(environment, TokenEnvironment::Mock);
11330
11331 // Test mock detection via password
11332 env::set_var("AMP_USERNAME", "real_user");
11333 env::set_var("AMP_PASSWORD", "mock_pass");
11334
11335 let environment = TokenEnvironment::detect();
11336 assert_eq!(environment, TokenEnvironment::Mock);
11337
11338 // Clean up
11339 env::remove_var("AMP_USERNAME");
11340 env::remove_var("AMP_PASSWORD");
11341 }
11342
11343 #[test]
11344 fn test_token_environment_detect_mock_via_base_url() {
11345 // Set up environment for mock detection via localhost URL
11346 env::remove_var("AMP_TESTS");
11347 env::set_var("AMP_USERNAME", "real_user");
11348 env::set_var("AMP_PASSWORD", "real_pass");
11349 env::set_var("AMP_API_BASE_URL", "http://localhost:8080/api");
11350
11351 let environment = TokenEnvironment::detect();
11352 assert_eq!(environment, TokenEnvironment::Mock);
11353
11354 // Test with 127.0.0.1
11355 env::set_var("AMP_API_BASE_URL", "http://127.0.0.1:3000/api");
11356 let environment = TokenEnvironment::detect();
11357 assert_eq!(environment, TokenEnvironment::Mock);
11358
11359 // Test with mock in URL
11360 env::set_var("AMP_API_BASE_URL", "http://mock-server.example.com/api");
11361 let environment = TokenEnvironment::detect();
11362 assert_eq!(environment, TokenEnvironment::Mock);
11363
11364 // Clean up
11365 env::remove_var("AMP_USERNAME");
11366 env::remove_var("AMP_PASSWORD");
11367 env::remove_var("AMP_API_BASE_URL");
11368 }
11369
11370 #[test]
11371 fn test_token_environment_detect_live_via_real_credentials() {
11372 // Set up environment for live detection via real credentials
11373 env::remove_var("AMP_TESTS");
11374 env::set_var("AMP_USERNAME", "real_user");
11375 env::set_var("AMP_PASSWORD", "real_pass");
11376 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
11377
11378 let environment = TokenEnvironment::detect();
11379 assert_eq!(environment, TokenEnvironment::Live);
11380
11381 // Clean up
11382 env::remove_var("AMP_USERNAME");
11383 env::remove_var("AMP_PASSWORD");
11384 env::remove_var("AMP_API_BASE_URL");
11385 }
11386
11387 #[test]
11388 fn test_token_environment_detect_mock_fallback() {
11389 // Set up environment with no credentials (fallback to mock)
11390 env::remove_var("AMP_TESTS");
11391 env::remove_var("AMP_USERNAME");
11392 env::remove_var("AMP_PASSWORD");
11393 env::remove_var("AMP_API_BASE_URL");
11394
11395 let environment = TokenEnvironment::detect();
11396 assert_eq!(environment, TokenEnvironment::Mock);
11397 }
11398
11399 #[test]
11400 fn test_has_mock_credentials() {
11401 // Test mock username detection
11402 assert!(TokenEnvironment::has_mock_credentials(
11403 "mock_user",
11404 "real_pass",
11405 ""
11406 ));
11407 assert!(TokenEnvironment::has_mock_credentials(
11408 "Mock_User",
11409 "real_pass",
11410 ""
11411 ));
11412 assert!(TokenEnvironment::has_mock_credentials(
11413 "user_mock",
11414 "real_pass",
11415 ""
11416 ));
11417
11418 // Test mock password detection
11419 assert!(TokenEnvironment::has_mock_credentials(
11420 "real_user",
11421 "mock_pass",
11422 ""
11423 ));
11424 assert!(TokenEnvironment::has_mock_credentials(
11425 "real_user",
11426 "Mock_Pass",
11427 ""
11428 ));
11429 assert!(TokenEnvironment::has_mock_credentials(
11430 "real_user",
11431 "pass_mock",
11432 ""
11433 ));
11434
11435 // Test mock URL detection
11436 assert!(TokenEnvironment::has_mock_credentials(
11437 "real_user",
11438 "real_pass",
11439 "http://localhost:8080"
11440 ));
11441 assert!(TokenEnvironment::has_mock_credentials(
11442 "real_user",
11443 "real_pass",
11444 "http://127.0.0.1:3000"
11445 ));
11446 assert!(TokenEnvironment::has_mock_credentials(
11447 "real_user",
11448 "real_pass",
11449 "http://mock-server.com"
11450 ));
11451 assert!(TokenEnvironment::has_mock_credentials(
11452 "real_user",
11453 "real_pass",
11454 "http://Mock-Server.com"
11455 ));
11456
11457 // Test non-mock credentials
11458 assert!(!TokenEnvironment::has_mock_credentials(
11459 "real_user",
11460 "real_pass",
11461 "https://amp-test.blockstream.com"
11462 ));
11463 assert!(!TokenEnvironment::has_mock_credentials("", "", ""));
11464 }
11465
11466 #[test]
11467 fn test_token_environment_should_persist_tokens() {
11468 assert!(!TokenEnvironment::Mock.should_persist_tokens());
11469 assert!(TokenEnvironment::Live.should_persist_tokens());
11470
11471 // Auto should delegate to detect()
11472 env::set_var("AMP_TESTS", "live");
11473 assert!(TokenEnvironment::Auto.should_persist_tokens());
11474
11475 env::set_var("AMP_USERNAME", "mock_user");
11476 env::set_var("AMP_PASSWORD", "some_password");
11477 env::remove_var("AMP_TESTS");
11478 env::remove_var("AMP_API_BASE_URL");
11479 assert!(!TokenEnvironment::Auto.should_persist_tokens());
11480
11481 // Clean up
11482 env::remove_var("AMP_USERNAME");
11483 env::remove_var("AMP_PASSWORD");
11484 }
11485
11486 #[test]
11487 fn test_token_environment_is_mock_and_is_live() {
11488 assert!(TokenEnvironment::Mock.is_mock());
11489 assert!(!TokenEnvironment::Mock.is_live());
11490
11491 assert!(!TokenEnvironment::Live.is_mock());
11492 assert!(TokenEnvironment::Live.is_live());
11493
11494 // Auto should delegate to detect()
11495 env::set_var("AMP_USERNAME", "mock_user");
11496 env::set_var("AMP_PASSWORD", "some_password");
11497 env::remove_var("AMP_TESTS");
11498 env::remove_var("AMP_API_BASE_URL");
11499 assert!(TokenEnvironment::Auto.is_mock());
11500 assert!(!TokenEnvironment::Auto.is_live());
11501
11502 env::set_var("AMP_TESTS", "live");
11503 assert!(!TokenEnvironment::Auto.is_mock());
11504 assert!(TokenEnvironment::Auto.is_live());
11505
11506 // Clean up
11507 env::remove_var("AMP_USERNAME");
11508 env::remove_var("AMP_PASSWORD");
11509 env::remove_var("AMP_TESTS");
11510 }
11511
11512 #[tokio::test]
11513 async fn test_token_environment_create_strategy_mock() {
11514 let mock_token = "test_mock_token".to_string();
11515 let strategy = TokenEnvironment::Mock
11516 .create_strategy(Some(mock_token.clone()))
11517 .await
11518 .unwrap();
11519
11520 assert_eq!(strategy.strategy_type(), "mock");
11521 assert!(!strategy.should_persist());
11522
11523 let token = strategy.get_token().await.unwrap();
11524 assert_eq!(token, mock_token);
11525 }
11526
11527 #[tokio::test]
11528 async fn test_token_environment_create_strategy_live() {
11529 let strategy = TokenEnvironment::Live.create_strategy(None).await.unwrap();
11530
11531 assert_eq!(strategy.strategy_type(), "live");
11532 assert!(strategy.should_persist());
11533 }
11534
11535 #[tokio::test]
11536 async fn test_token_environment_create_auto_strategy() {
11537 // Test with mock environment - need both username and password for proper detection
11538 env::set_var("AMP_USERNAME", "mock_user");
11539 env::set_var("AMP_PASSWORD", "some_password");
11540 env::remove_var("AMP_TESTS");
11541 env::remove_var("AMP_API_BASE_URL");
11542
11543 let mock_token = "auto_mock_token".to_string();
11544 let strategy = TokenEnvironment::create_auto_strategy(Some(mock_token.clone()))
11545 .await
11546 .unwrap();
11547
11548 assert_eq!(strategy.strategy_type(), "mock");
11549 let token = strategy.get_token().await.unwrap();
11550 assert_eq!(token, mock_token);
11551
11552 // Clean up
11553 env::remove_var("AMP_USERNAME");
11554 env::remove_var("AMP_PASSWORD");
11555 }
11556
11557 #[tokio::test]
11558 async fn test_mock_token_strategy_factory_methods() {
11559 // Test with_default_token
11560 let strategy = MockTokenStrategy::with_default_token();
11561 assert_eq!(strategy.strategy_type(), "mock");
11562 let token = strategy.get_token().await.unwrap();
11563 assert_eq!(token, "mock_token_default");
11564
11565 // Test for_test
11566 let strategy = MockTokenStrategy::for_test("my_test");
11567 let token = strategy.get_token().await.unwrap();
11568 assert_eq!(token, "mock_token_my_test");
11569 }
11570
11571 #[tokio::test]
11572 async fn test_live_token_strategy_factory_methods() {
11573 // Test for_testing
11574 let strategy = LiveTokenStrategy::for_testing().await.unwrap();
11575 assert_eq!(strategy.strategy_type(), "live");
11576 assert!(strategy.should_persist());
11577 }
11578
11579 #[tokio::test]
11580 async fn test_standalone_factory_functions() {
11581 // Test create_mock_token_strategy
11582 let mock_token = "standalone_mock".to_string();
11583 let strategy = create_mock_token_strategy(mock_token.clone());
11584 assert_eq!(strategy.strategy_type(), "mock");
11585 let token = strategy.get_token().await.unwrap();
11586 assert_eq!(token, mock_token);
11587
11588 // Test create_live_token_strategy
11589 let strategy = create_live_token_strategy().await.unwrap();
11590 assert_eq!(strategy.strategy_type(), "live");
11591
11592 // Test create_auto_token_strategy with mock environment
11593 env::set_var("AMP_USERNAME", "mock_user");
11594 env::set_var("AMP_PASSWORD", "some_password");
11595 env::remove_var("AMP_TESTS");
11596 env::remove_var("AMP_API_BASE_URL");
11597
11598 let auto_mock_token = "auto_standalone_mock".to_string();
11599 let strategy = create_auto_token_strategy(Some(auto_mock_token.clone()))
11600 .await
11601 .unwrap();
11602 assert_eq!(strategy.strategy_type(), "mock");
11603 let token = strategy.get_token().await.unwrap();
11604 assert_eq!(token, auto_mock_token);
11605
11606 // Test create_token_strategy_for_environment
11607 let env_mock_token = "env_mock".to_string();
11608 let strategy = create_token_strategy_for_environment(
11609 TokenEnvironment::Mock,
11610 Some(env_mock_token.clone()),
11611 )
11612 .await
11613 .unwrap();
11614 assert_eq!(strategy.strategy_type(), "mock");
11615 let token = strategy.get_token().await.unwrap();
11616 assert_eq!(token, env_mock_token);
11617
11618 // Clean up
11619 env::remove_var("AMP_USERNAME");
11620 env::remove_var("AMP_PASSWORD");
11621 }
11622
11623 #[test]
11624 fn test_environment_detection_with_various_credential_combinations() {
11625 // Test case 1: AMP_TESTS=live overrides everything
11626 env::set_var("AMP_TESTS", "live");
11627 env::set_var("AMP_USERNAME", "mock_user");
11628 env::set_var("AMP_PASSWORD", "mock_pass");
11629 env::set_var("AMP_API_BASE_URL", "http://localhost:8080");
11630 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Live);
11631
11632 // Test case 2: Mock username with real password and URL
11633 env::remove_var("AMP_TESTS");
11634 env::set_var("AMP_USERNAME", "mock_user");
11635 env::set_var("AMP_PASSWORD", "real_password");
11636 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
11637 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
11638
11639 // Test case 3: Real username with mock password
11640 env::set_var("AMP_USERNAME", "real_user");
11641 env::set_var("AMP_PASSWORD", "mock_password");
11642 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
11643 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
11644
11645 // Test case 4: Real credentials with localhost URL
11646 env::set_var("AMP_USERNAME", "real_user");
11647 env::set_var("AMP_PASSWORD", "real_password");
11648 env::set_var("AMP_API_BASE_URL", "http://localhost:3000/api");
11649 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
11650
11651 // Test case 5: All real credentials
11652 env::set_var("AMP_USERNAME", "real_user");
11653 env::set_var("AMP_PASSWORD", "real_password");
11654 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
11655 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Live);
11656
11657 // Test case 6: Empty credentials
11658 env::remove_var("AMP_USERNAME");
11659 env::remove_var("AMP_PASSWORD");
11660 env::remove_var("AMP_API_BASE_URL");
11661 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
11662
11663 // Test case 7: Only username set
11664 env::set_var("AMP_USERNAME", "real_user");
11665 env::remove_var("AMP_PASSWORD");
11666 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
11667
11668 // Test case 8: Only password set
11669 env::remove_var("AMP_USERNAME");
11670 env::set_var("AMP_PASSWORD", "real_password");
11671 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
11672
11673 // Clean up all environment variables
11674 env::remove_var("AMP_TESTS");
11675 env::remove_var("AMP_USERNAME");
11676 env::remove_var("AMP_PASSWORD");
11677 env::remove_var("AMP_API_BASE_URL");
11678 }
11679
11680 #[tokio::test]
11681 async fn test_distribute_asset_input_validation() {
11682 // Create a mock client for testing
11683 let client = ApiClient::with_mock_token(
11684 reqwest::Url::parse("http://localhost:8080/api").unwrap(),
11685 "test_token".to_string(),
11686 )
11687 .unwrap();
11688
11689 // Test invalid asset UUID
11690 let assignments = vec![AssetDistributionAssignment {
11691 user_id: "user123".to_string(),
11692 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
11693 amount: 100.0,
11694 }];
11695
11696 // Create a mock ElementsRpc (this will fail connection validation, but that's expected)
11697 let elements_rpc = ElementsRpc::new(
11698 "http://localhost:18884".to_string(),
11699 "user".to_string(),
11700 "pass".to_string(),
11701 );
11702
11703 // Create a mock signer
11704 let (_, signer) = LwkSoftwareSigner::generate_new().unwrap();
11705
11706 // Test with invalid UUID format
11707 let result = client
11708 .distribute_asset(
11709 "invalid-uuid",
11710 assignments.clone(),
11711 &elements_rpc,
11712 "test_wallet",
11713 &signer,
11714 )
11715 .await;
11716
11717 assert!(result.is_err());
11718 if let Err(AmpError::Validation(msg)) = result {
11719 assert!(msg.contains("Invalid asset UUID"));
11720 } else {
11721 panic!("Expected validation error for invalid UUID");
11722 }
11723
11724 // Test with empty assignments
11725 let result = client
11726 .distribute_asset(
11727 "550e8400-e29b-41d4-a716-446655440000",
11728 vec![],
11729 &elements_rpc,
11730 "test_wallet",
11731 &signer,
11732 )
11733 .await;
11734
11735 assert!(result.is_err());
11736 if let Err(AmpError::Validation(msg)) = result {
11737 assert!(msg.contains("Invalid assignments"));
11738 } else {
11739 panic!("Expected validation error for empty assignments");
11740 }
11741 }
11742
11743 #[test]
11744 fn test_validate_asset_uuid() {
11745 let _client = ApiClient::with_mock_token(
11746 reqwest::Url::parse("http://localhost:8080/api").unwrap(),
11747 "test_token".to_string(),
11748 )
11749 .unwrap();
11750
11751 // Valid UUID
11752 assert!(ApiClient::validate_asset_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
11753
11754 // Invalid UUIDs
11755 assert!(ApiClient::validate_asset_uuid("").is_err());
11756 assert!(ApiClient::validate_asset_uuid("invalid").is_err());
11757 assert!(ApiClient::validate_asset_uuid("550e8400-e29b-41d4-a716").is_err()); // Too short
11758 assert!(
11759 ApiClient::validate_asset_uuid("550e8400-e29b-41d4-a716-446655440000-extra").is_err()
11760 ); // Too long
11761 assert!(ApiClient::validate_asset_uuid("550e8400xe29bx41d4xa716x446655440000").is_err()); // Wrong separators
11762 assert!(ApiClient::validate_asset_uuid("550e8400-e29g-41d4-a716-446655440000").is_err());
11763 // Invalid hex char
11764 }
11765
11766 #[test]
11767 fn test_validate_assignments() {
11768 let _client = ApiClient::with_mock_token(
11769 reqwest::Url::parse("http://localhost:8080/api").unwrap(),
11770 "test_token".to_string(),
11771 )
11772 .unwrap();
11773
11774 // Valid assignments
11775 let valid_assignments = vec![AssetDistributionAssignment {
11776 user_id: "user123".to_string(),
11777 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
11778 amount: 100.0,
11779 }];
11780 assert!(ApiClient::validate_assignments(&valid_assignments).is_ok());
11781
11782 // Empty assignments
11783 assert!(ApiClient::validate_assignments(&[]).is_err());
11784
11785 // Assignment with empty user_id
11786 let invalid_assignments = vec![AssetDistributionAssignment {
11787 user_id: "".to_string(),
11788 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
11789 amount: 100.0,
11790 }];
11791 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
11792
11793 // Assignment with empty address
11794 let invalid_assignments = vec![AssetDistributionAssignment {
11795 user_id: "user123".to_string(),
11796 address: "".to_string(),
11797 amount: 100.0,
11798 }];
11799 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
11800
11801 // Assignment with invalid address format
11802 let invalid_assignments = vec![AssetDistributionAssignment {
11803 user_id: "user123".to_string(),
11804 address: "invalid_address".to_string(),
11805 amount: 100.0,
11806 }];
11807 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
11808
11809 // Assignment with non-positive amount
11810 let invalid_assignments = vec![AssetDistributionAssignment {
11811 user_id: "user123".to_string(),
11812 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
11813 amount: 0.0,
11814 }];
11815 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
11816
11817 // Assignment with unreasonably large amount
11818 let invalid_assignments = vec![AssetDistributionAssignment {
11819 user_id: "user123".to_string(),
11820 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
11821 amount: 25_000_000.0,
11822 }];
11823 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
11824 }
11825
11826 #[test]
11827 fn test_enhanced_error_handling_and_logging() {
11828 // Test AmpError creation and context enhancement
11829 let api_error = AmpError::api("Distribution creation failed");
11830 let contextual_error = api_error.with_context("Step 6: Distribution creation");
11831
11832 match contextual_error {
11833 AmpError::Api(msg) => {
11834 assert!(msg.contains("Step 6: Distribution creation"));
11835 assert!(msg.contains("Distribution creation failed"));
11836 }
11837 _ => panic!("Expected Api error variant"),
11838 }
11839
11840 // Test retry instructions for different error types
11841 let rpc_error = AmpError::rpc("Connection failed");
11842 assert!(rpc_error.is_retryable());
11843 assert!(rpc_error.retry_instructions().is_some());
11844 assert!(rpc_error
11845 .retry_instructions()
11846 .unwrap()
11847 .contains("Elements node"));
11848
11849 let validation_error = AmpError::validation("Invalid UUID");
11850 assert!(!validation_error.is_retryable());
11851 assert!(validation_error.retry_instructions().is_none());
11852
11853 let timeout_error = AmpError::timeout("Confirmation timeout for txid abc123");
11854 assert!(!timeout_error.is_retryable());
11855 let instructions = timeout_error.retry_instructions();
11856 assert!(instructions.is_some());
11857 assert!(instructions.unwrap().contains("transaction ID"));
11858
11859 // Test error helper methods
11860 let signer_error =
11861 AmpError::Signer(crate::signer::SignerError::Lwk("Test error".to_string()));
11862 assert!(!signer_error.is_retryable());
11863 assert!(signer_error.retry_instructions().is_none());
11864
11865 // Test serialization error
11866 let json_error = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
11867 let serialization_error = AmpError::from(json_error);
11868 assert!(matches!(serialization_error, AmpError::Serialization(_)));
11869 assert!(!serialization_error.is_retryable());
11870 }
11871}