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, ReceivedByAddress, RegisterAssetResponse, TokenData, TokenInfo, TokenRequest,
26 TokenResponse, TransactionDetail, TxInput, Unspent, Utxo,
27};
28use crate::signer::{Signer, SignerError};
29
30/// Environment variables used for token environment detection
31#[derive(Debug)]
32struct EnvironmentVariables {
33 username: String,
34 password: String,
35 amp_tests: String,
36 base_url: String,
37}
38
39/// Token environment detection for automatic strategy selection
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum TokenEnvironment {
42 /// Mock environment - use isolated token management without persistence
43 Mock,
44 /// Live environment - use full token management with persistence
45 Live,
46 /// Auto-detect environment based on credentials and settings
47 Auto,
48}
49
50impl TokenEnvironment {
51 /// Detects the current token environment based on environment variables and credential patterns
52 ///
53 /// Detection logic:
54 /// 1. If `AMP_TESTS=live` is set, returns `Live`
55 /// 2. If credentials contain "mock" string, returns `Mock`
56 /// 3. If real credentials are present without live test flag, returns `Live`
57 /// 4. Fallback to `Mock` for safety
58 #[must_use]
59 pub fn detect() -> Self {
60 let env_vars = Self::read_environment_variables();
61 Self::log_detection_start(&env_vars);
62
63 if Self::is_explicit_live_environment(&env_vars.amp_tests) {
64 return Self::Live;
65 }
66
67 if Self::has_mock_credentials(&env_vars.username, &env_vars.password, &env_vars.base_url) {
68 Self::log_detection_result("mock environment via mock credentials");
69 return Self::Mock;
70 }
71
72 if Self::has_real_credentials(&env_vars.username, &env_vars.password) {
73 Self::log_detection_result("live environment via real credentials");
74 return Self::Live;
75 }
76
77 Self::log_detection_result("mock environment via fallback (no credentials)");
78 Self::Mock
79 }
80
81 /// Reads environment variables needed for token environment detection
82 fn read_environment_variables() -> EnvironmentVariables {
83 EnvironmentVariables {
84 username: env::var("AMP_USERNAME").unwrap_or_default(),
85 password: env::var("AMP_PASSWORD").unwrap_or_default(),
86 amp_tests: env::var("AMP_TESTS").unwrap_or_default(),
87 base_url: env::var("AMP_API_BASE_URL").unwrap_or_default(),
88 }
89 }
90
91 /// Logs the start of environment detection with current variable values
92 fn log_detection_start(env_vars: &EnvironmentVariables) {
93 tracing::debug!(
94 "Detecting token environment - AMP_TESTS: '{}', username: '{}', base_url: '{}'",
95 env_vars.amp_tests,
96 env_vars.username,
97 env_vars.base_url
98 );
99 }
100
101 /// Checks if the environment is explicitly set to live testing
102 fn is_explicit_live_environment(amp_tests: &str) -> bool {
103 if amp_tests == "live" {
104 Self::log_detection_result("live environment via AMP_TESTS=live");
105 true
106 } else {
107 false
108 }
109 }
110
111 /// Checks if real (non-empty) credentials are present
112 const fn has_real_credentials(username: &str, password: &str) -> bool {
113 !username.is_empty() && !password.is_empty()
114 }
115
116 /// Logs the final detection result
117 fn log_detection_result(reason: &str) {
118 tracing::info!("Detected {}", reason);
119 }
120
121 /// Checks if the provided credentials indicate a mock environment
122 ///
123 /// Mock credentials are detected by:
124 /// - Username containing "mock" (case-insensitive)
125 /// - Password containing "mock" (case-insensitive)
126 /// - Base URL containing localhost, 127.0.0.1, or "mock"
127 #[must_use]
128 pub fn has_mock_credentials(username: &str, password: &str, base_url: &str) -> bool {
129 let username_lower = username.to_lowercase();
130 let password_lower = password.to_lowercase();
131 let base_url_lower = base_url.to_lowercase();
132
133 let has_mock_username = username_lower.contains("mock");
134 let has_mock_password = password_lower.contains("mock");
135 let has_mock_url = base_url_lower.contains("localhost")
136 || base_url_lower.contains("127.0.0.1")
137 || base_url_lower.contains("mock");
138
139 let is_mock = has_mock_username || has_mock_password || has_mock_url;
140
141 tracing::debug!(
142 "Mock credential check - username: {}, password: {}, url: {}, result: {}",
143 has_mock_username,
144 has_mock_password,
145 has_mock_url,
146 is_mock
147 );
148
149 is_mock
150 }
151
152 /// Creates a token strategy based on the environment type
153 ///
154 /// # Arguments
155 /// * `mock_token` - Optional mock token to use for mock environments
156 ///
157 /// # Errors
158 /// Returns an error if strategy creation fails
159 pub async fn create_strategy(
160 &self,
161 mock_token: Option<String>,
162 ) -> Result<Box<dyn TokenStrategy>, Error> {
163 match self {
164 Self::Mock => Ok(Self::create_mock_strategy(mock_token)),
165 Self::Live => Self::create_live_strategy().await,
166 Self::Auto => Self::create_auto_detected_strategy(mock_token).await,
167 }
168 }
169
170 /// Creates a mock token strategy with the provided or default token
171 fn create_mock_strategy(mock_token: Option<String>) -> Box<dyn TokenStrategy> {
172 let token = mock_token.unwrap_or_else(|| "default_mock_token".to_string());
173 tracing::debug!("Creating mock token strategy with token");
174 Box::new(MockTokenStrategy::new(token))
175 }
176
177 /// Creates a live token strategy
178 async fn create_live_strategy() -> Result<Box<dyn TokenStrategy>, Error> {
179 tracing::debug!("Creating live token strategy");
180 let strategy = LiveTokenStrategy::new().await?;
181 Ok(Box::new(strategy))
182 }
183
184 /// Creates a strategy based on auto-detected environment
185 async fn create_auto_detected_strategy(
186 mock_token: Option<String>,
187 ) -> Result<Box<dyn TokenStrategy>, Error> {
188 tracing::debug!("Auto-detecting environment for strategy creation");
189 let detected = Self::detect();
190
191 match detected {
192 Self::Mock => Ok(Self::create_auto_detected_mock_strategy(mock_token)),
193 Self::Live => Self::create_auto_detected_live_strategy().await,
194 Self::Auto => Self::handle_unexpected_auto_detection(),
195 }
196 }
197
198 /// Creates a mock strategy for auto-detected mock environment
199 fn create_auto_detected_mock_strategy(mock_token: Option<String>) -> Box<dyn TokenStrategy> {
200 let token = mock_token.unwrap_or_else(|| "default_mock_token".to_string());
201 tracing::debug!("Auto-detected mock environment, creating mock strategy");
202 Box::new(MockTokenStrategy::new(token))
203 }
204
205 /// Creates a live strategy for auto-detected live environment
206 async fn create_auto_detected_live_strategy() -> Result<Box<dyn TokenStrategy>, Error> {
207 tracing::debug!("Auto-detected live environment, creating live strategy");
208 let strategy = LiveTokenStrategy::new().await?;
209 Ok(Box::new(strategy))
210 }
211
212 /// Handles the unexpected case where `detect()` returns Auto
213 fn handle_unexpected_auto_detection() -> Result<Box<dyn TokenStrategy>, Error> {
214 tracing::error!("Unexpected Auto environment from detect()");
215 Err(Error::Token(TokenError::validation(
216 "Environment detection returned Auto, which should not happen".to_string(),
217 )))
218 }
219
220 /// Creates a token strategy with automatic environment detection
221 ///
222 /// This is a convenience method that combines environment detection with strategy creation.
223 ///
224 /// # Arguments
225 /// * `mock_token` - Optional mock token to use if mock environment is detected
226 ///
227 /// # Errors
228 /// Returns an error if strategy creation fails
229 pub async fn create_auto_strategy(
230 mock_token: Option<String>,
231 ) -> Result<Box<dyn TokenStrategy>, Error> {
232 let environment = Self::detect();
233 environment.create_strategy(mock_token).await
234 }
235
236 /// Determines if token persistence should be enabled for this environment
237 #[must_use]
238 pub fn should_persist_tokens(&self) -> bool {
239 match self {
240 Self::Mock => false,
241 Self::Live => true,
242 Self::Auto => Self::detect().should_persist_tokens(),
243 }
244 }
245
246 /// Returns true if this is a mock environment
247 #[must_use]
248 pub fn is_mock(&self) -> bool {
249 matches!(self, Self::Mock) || (matches!(self, Self::Auto) && Self::detect().is_mock())
250 }
251
252 /// Returns true if this is a live environment
253 #[must_use]
254 pub fn is_live(&self) -> bool {
255 matches!(self, Self::Live) || (matches!(self, Self::Auto) && Self::detect().is_live())
256 }
257}
258
259/// Token management strategy trait for different token handling approaches
260#[async_trait]
261pub trait TokenStrategy: Send + Sync + std::fmt::Debug {
262 /// Gets a valid authentication token
263 async fn get_token(&self) -> Result<String, Error>;
264
265 /// Clears stored token (for testing)
266 async fn clear_token(&self) -> Result<(), Error>;
267
268 /// Returns whether this strategy should persist tokens
269 fn should_persist(&self) -> bool;
270
271 /// Returns the strategy type for debugging
272 fn strategy_type(&self) -> &'static str;
273
274 /// Returns self as Any for downcasting (used internally)
275 fn as_any(&self) -> &dyn std::any::Any;
276}
277
278/// Mock token strategy for isolated testing without persistence
279#[derive(Debug, Clone)]
280pub struct MockTokenStrategy {
281 token: String,
282}
283
284impl MockTokenStrategy {
285 /// Creates a new mock token strategy with the provided token
286 #[must_use]
287 pub const fn new(token: String) -> Self {
288 Self { token }
289 }
290
291 /// Creates a mock token strategy with a default test token
292 #[must_use]
293 pub fn with_default_token() -> Self {
294 Self::new("mock_token_default".to_string())
295 }
296
297 /// Creates a mock token strategy for a specific test case
298 #[must_use]
299 pub fn for_test(test_name: &str) -> Self {
300 Self::new(format!("mock_token_{test_name}"))
301 }
302}
303
304#[async_trait]
305impl TokenStrategy for MockTokenStrategy {
306 async fn get_token(&self) -> Result<String, Error> {
307 tracing::debug!("Using mock token strategy - returning pre-set token");
308 Ok(self.token.clone())
309 }
310
311 async fn clear_token(&self) -> Result<(), Error> {
312 tracing::debug!("Mock token strategy - clear_token is a no-op");
313 Ok(())
314 }
315
316 fn should_persist(&self) -> bool {
317 false
318 }
319
320 fn strategy_type(&self) -> &'static str {
321 "mock"
322 }
323
324 fn as_any(&self) -> &dyn std::any::Any {
325 self
326 }
327}
328
329/// Live token strategy that wraps the existing `TokenManager` for full token management
330#[derive(Debug)]
331pub struct LiveTokenStrategy {
332 token_manager: Arc<TokenManager>,
333}
334
335impl LiveTokenStrategy {
336 /// Creates a new live token strategy using the global `TokenManager` instance
337 ///
338 /// # Errors
339 /// Returns an error if the `TokenManager` cannot be initialized
340 pub async fn new() -> Result<Self, Error> {
341 let token_manager = TokenManager::get_global_instance().await?;
342 Ok(Self { token_manager })
343 }
344
345 /// Creates a new live token strategy with a custom `TokenManager`
346 #[must_use]
347 pub const fn with_token_manager(token_manager: Arc<TokenManager>) -> Self {
348 Self { token_manager }
349 }
350
351 /// Creates a live token strategy with custom retry configuration
352 ///
353 /// # Errors
354 /// Returns an error if the `TokenManager` cannot be initialized
355 pub async fn with_config(config: RetryConfig) -> Result<Self, Error> {
356 let base_url = get_amp_api_base_url()?;
357 let token_manager =
358 Arc::new(TokenManager::with_config_and_base_url(config, base_url).await?);
359 Ok(Self { token_manager })
360 }
361
362 /// Creates a live token strategy optimized for testing
363 ///
364 /// # Errors
365 /// Returns an error if the `TokenManager` cannot be initialized
366 pub async fn for_testing() -> Result<Self, Error> {
367 let config = RetryConfig::for_tests();
368 Self::with_config(config).await
369 }
370
371 /// Gets current token information for debugging and monitoring
372 ///
373 /// # Errors
374 /// Returns an error if token information retrieval fails
375 pub async fn get_token_info(&self) -> Result<Option<TokenInfo>, Error> {
376 self.token_manager.get_token_info().await
377 }
378}
379
380#[async_trait]
381impl TokenStrategy for LiveTokenStrategy {
382 async fn get_token(&self) -> Result<String, Error> {
383 tracing::debug!("Using live token strategy - full token management");
384 self.token_manager.get_token().await
385 }
386
387 async fn clear_token(&self) -> Result<(), Error> {
388 self.token_manager.clear_token().await
389 }
390
391 fn should_persist(&self) -> bool {
392 true
393 }
394
395 fn strategy_type(&self) -> &'static str {
396 "live"
397 }
398
399 fn as_any(&self) -> &dyn std::any::Any {
400 self
401 }
402}
403
404#[derive(Error, Debug)]
405pub enum Error {
406 #[error("Missing {0} environment variable")]
407 MissingEnvVar(String),
408 #[error("AMP request failed: {0}")]
409 RequestFailed(String),
410 #[error("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 a specific wallet as watch-only
1654 ///
1655 /// # Arguments
1656 /// * `wallet_name` - Name of the wallet
1657 /// * `address` - The address to import
1658 /// * `label` - Optional label for the address
1659 /// * `rescan` - Optional whether to rescan the blockchain (default: false)
1660 ///
1661 /// # Errors
1662 /// Returns an error if the RPC call fails
1663 ///
1664 /// # Examples
1665 /// ```no_run
1666 /// # use amp_rs::ElementsRpc;
1667 /// # #[tokio::main]
1668 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1669 /// let rpc = ElementsRpc::from_env()?;
1670 /// rpc.import_address("my_wallet", "vjU8L4dKa1XyyVcPqKBbTgjT1tRC7qYp5VJGwndZSCFk4ntpWey1pQe6hcSGDMVurr9CsZ21EGsqGjWA", Some("test_address"), Some(false)).await?;
1671 /// # Ok(())
1672 /// # }
1673 /// ```
1674 pub async fn import_address(
1675 &self,
1676 wallet_name: &str,
1677 address: &str,
1678 label: Option<&str>,
1679 rescan: Option<bool>,
1680 ) -> Result<(), AmpError> {
1681 let rescan_value = rescan.unwrap_or(false);
1682 tracing::debug!(
1683 "Importing address: {} into wallet: {} with label: {:?}, rescan: {}",
1684 address,
1685 wallet_name,
1686 label,
1687 rescan_value
1688 );
1689
1690 let params = serde_json::json!([address, label.unwrap_or(""), rescan_value]);
1691
1692 // importaddress returns null on success
1693 let request = RpcRequest {
1694 jsonrpc: "1.0".to_string(),
1695 id: "amp-client".to_string(),
1696 method: "importaddress".to_string(),
1697 params,
1698 };
1699
1700 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1701
1702 let response = self
1703 .client
1704 .post(&wallet_url)
1705 .basic_auth(&self.username, Some(&self.password))
1706 .json(&request)
1707 .send()
1708 .await
1709 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1710
1711 if !response.status().is_success() {
1712 return Err(AmpError::rpc(format!(
1713 "RPC request failed with status: {}",
1714 response.status()
1715 )));
1716 }
1717
1718 let rpc_response: RpcResponse<serde_json::Value> = response
1719 .json()
1720 .await
1721 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1722
1723 if let Some(error) = rpc_response.error {
1724 return Err(AmpError::rpc(format!(
1725 "RPC error {}: {}",
1726 error.code, error.message
1727 )));
1728 }
1729
1730 tracing::debug!(
1731 "Successfully imported address: {} into wallet: {}",
1732 address,
1733 wallet_name
1734 );
1735 Ok(())
1736 }
1737
1738 /// Rescans the blockchain for a wallet
1739 ///
1740 /// # Arguments
1741 /// * `wallet_name` - Name of the wallet to rescan
1742 /// * `start_height` - Optional start height for rescan (default: 0)
1743 ///
1744 /// # Errors
1745 /// Returns an error if the RPC call fails
1746 ///
1747 /// # Examples
1748 /// ```no_run
1749 /// # use amp_rs::ElementsRpc;
1750 /// # #[tokio::main]
1751 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1752 /// let rpc = ElementsRpc::from_env()?;
1753 /// let result = rpc.rescan_blockchain("my_wallet", None).await?;
1754 /// # Ok(())
1755 /// # }
1756 /// ```
1757 pub async fn rescan_blockchain(
1758 &self,
1759 wallet_name: &str,
1760 start_height: Option<u64>,
1761 ) -> Result<serde_json::Value, AmpError> {
1762 tracing::debug!("Rescanning blockchain for wallet: {}", wallet_name);
1763
1764 let params = start_height.map_or_else(
1765 || serde_json::json!([]),
1766 |height| serde_json::json!([height]),
1767 );
1768
1769 let request = RpcRequest {
1770 jsonrpc: "1.0".to_string(),
1771 id: "amp-client".to_string(),
1772 method: "rescanblockchain".to_string(),
1773 params,
1774 };
1775
1776 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1777
1778 let response = self
1779 .client
1780 .post(&wallet_url)
1781 .basic_auth(&self.username, Some(&self.password))
1782 .json(&request)
1783 .send()
1784 .await
1785 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1786
1787 if !response.status().is_success() {
1788 return Err(AmpError::rpc(format!(
1789 "RPC request failed with status: {}",
1790 response.status()
1791 )));
1792 }
1793
1794 let rpc_response: RpcResponse<serde_json::Value> = response
1795 .json()
1796 .await
1797 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1798
1799 if let Some(error) = rpc_response.error {
1800 return Err(AmpError::rpc(format!(
1801 "RPC error rescanning blockchain: {} (code: {})",
1802 error.message, error.code
1803 )));
1804 }
1805
1806 let result = rpc_response
1807 .result
1808 .ok_or_else(|| AmpError::rpc("No result returned from rescanblockchain".to_string()))?;
1809
1810 tracing::debug!(
1811 "Successfully rescanned blockchain for wallet: {}",
1812 wallet_name
1813 );
1814 Ok(result)
1815 }
1816
1817 /// Creates or loads a wallet
1818 ///
1819 /// # Arguments
1820 /// * `wallet_name` - Name of the wallet to create or load
1821 /// * `disable_private_keys` - Whether to disable private keys (watch-only wallet)
1822 ///
1823 /// # Errors
1824 /// Returns an error if the RPC call fails
1825 ///
1826 /// # Examples
1827 /// ```no_run
1828 /// # use amp_rs::ElementsRpc;
1829 /// # #[tokio::main]
1830 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1831 /// let rpc = ElementsRpc::from_env()?;
1832 /// rpc.create_wallet("test_wallet", true).await?;
1833 /// # Ok(())
1834 /// # }
1835 /// ```
1836 pub async fn create_wallet(
1837 &self,
1838 wallet_name: &str,
1839 disable_private_keys: bool,
1840 ) -> Result<(), AmpError> {
1841 tracing::debug!(
1842 "Creating wallet: {} with disable_private_keys: {}",
1843 wallet_name,
1844 disable_private_keys
1845 );
1846
1847 let params = serde_json::json!([wallet_name, disable_private_keys]);
1848
1849 let request = RpcRequest {
1850 jsonrpc: "1.0".to_string(),
1851 id: "amp-client".to_string(),
1852 method: "createwallet".to_string(),
1853 params,
1854 };
1855
1856 let response = self
1857 .client
1858 .post(&self.base_url)
1859 .basic_auth(&self.username, Some(&self.password))
1860 .json(&request)
1861 .send()
1862 .await
1863 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1864
1865 if !response.status().is_success() {
1866 return Err(AmpError::rpc(format!(
1867 "RPC request failed with status: {}",
1868 response.status()
1869 )));
1870 }
1871
1872 let rpc_response: RpcResponse<serde_json::Value> = response
1873 .json()
1874 .await
1875 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1876
1877 if let Some(error) = rpc_response.error {
1878 // Ignore "wallet already exists" error
1879 if error.code != -4 {
1880 return Err(AmpError::rpc(format!(
1881 "RPC error {}: {}",
1882 error.code, error.message
1883 )));
1884 }
1885 tracing::debug!("Wallet {} already exists", wallet_name);
1886 } else {
1887 tracing::debug!("Successfully created wallet: {}", wallet_name);
1888 }
1889
1890 Ok(())
1891 }
1892
1893 /// Loads an existing wallet
1894 ///
1895 /// # Arguments
1896 /// * `wallet_name` - Name of the wallet to load
1897 ///
1898 /// # Errors
1899 /// Returns an error if the RPC call fails
1900 ///
1901 /// # Examples
1902 /// ```no_run
1903 /// # use amp_rs::ElementsRpc;
1904 /// # #[tokio::main]
1905 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1906 /// let rpc = ElementsRpc::from_env()?;
1907 /// rpc.load_wallet("test_wallet").await?;
1908 /// # Ok(())
1909 /// # }
1910 /// ```
1911 #[allow(clippy::cognitive_complexity)]
1912 pub async fn load_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
1913 tracing::debug!("Loading wallet: {}", wallet_name);
1914
1915 let params = serde_json::json!([wallet_name]);
1916
1917 let request = RpcRequest {
1918 jsonrpc: "1.0".to_string(),
1919 id: "amp-client".to_string(),
1920 method: "loadwallet".to_string(),
1921 params,
1922 };
1923
1924 let response = self
1925 .client
1926 .post(&self.base_url)
1927 .basic_auth(&self.username, Some(&self.password))
1928 .json(&request)
1929 .send()
1930 .await
1931 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1932
1933 if !response.status().is_success() {
1934 let status = response.status();
1935 let error_body = response
1936 .text()
1937 .await
1938 .unwrap_or_else(|_| "Unable to read error body".to_string());
1939 tracing::debug!(
1940 "Load wallet failed with status: {} - Body: {}",
1941 status,
1942 error_body
1943 );
1944
1945 // For wallet loading, we want to be more permissive with errors
1946 // since the wallet might already be loaded
1947 if status == 500 && error_body.contains("already loaded") {
1948 tracing::debug!(
1949 "Wallet {} appears to already be loaded (500 error)",
1950 wallet_name
1951 );
1952 return Ok(());
1953 }
1954
1955 return Err(AmpError::rpc(format!(
1956 "RPC request failed with status: {status} - Body: {error_body}"
1957 )));
1958 }
1959
1960 let rpc_response: RpcResponse<serde_json::Value> = response
1961 .json()
1962 .await
1963 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1964
1965 if let Some(error) = rpc_response.error {
1966 // Ignore "wallet already loaded" error
1967 if error.code != -35 {
1968 return Err(AmpError::rpc(format!(
1969 "RPC error {}: {}",
1970 error.code, error.message
1971 )));
1972 }
1973 tracing::debug!("Wallet {} already loaded", wallet_name);
1974 } else {
1975 tracing::debug!("Successfully loaded wallet: {}", wallet_name);
1976 }
1977
1978 Ok(())
1979 }
1980
1981 /// Unloads a wallet
1982 ///
1983 /// # Arguments
1984 /// * `wallet_name` - Name of the wallet to unload
1985 ///
1986 /// # Errors
1987 /// Returns an error if the RPC call fails
1988 ///
1989 /// # Examples
1990 /// ```no_run
1991 /// # use amp_rs::ElementsRpc;
1992 /// # #[tokio::main]
1993 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1994 /// let rpc = ElementsRpc::from_env()?;
1995 /// rpc.unload_wallet("test_wallet").await?;
1996 /// # Ok(())
1997 /// # }
1998 /// ```
1999 pub async fn unload_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
2000 tracing::debug!("Unloading wallet: {}", wallet_name);
2001
2002 let params = serde_json::json!([wallet_name]);
2003
2004 let request = RpcRequest {
2005 jsonrpc: "1.0".to_string(),
2006 id: "amp-client".to_string(),
2007 method: "unloadwallet".to_string(),
2008 params,
2009 };
2010
2011 let response = self
2012 .client
2013 .post(&self.base_url)
2014 .basic_auth(&self.username, Some(&self.password))
2015 .json(&request)
2016 .send()
2017 .await
2018 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
2019
2020 if !response.status().is_success() {
2021 return Err(AmpError::rpc(format!(
2022 "RPC request failed with status: {}",
2023 response.status()
2024 )));
2025 }
2026
2027 let rpc_response: RpcResponse<serde_json::Value> = response
2028 .json()
2029 .await
2030 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
2031
2032 if let Some(error) = rpc_response.error {
2033 return Err(AmpError::rpc(format!(
2034 "RPC error {}: {}",
2035 error.code, error.message
2036 )));
2037 }
2038
2039 tracing::debug!("Successfully unloaded wallet: {}", wallet_name);
2040 Ok(())
2041 }
2042
2043 /// Lists all available wallets
2044 ///
2045 /// # Errors
2046 /// Returns an error if the RPC call fails
2047 ///
2048 /// # Examples
2049 /// ```no_run
2050 /// # use amp_rs::ElementsRpc;
2051 /// # #[tokio::main]
2052 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2053 /// let rpc = ElementsRpc::from_env()?;
2054 /// let wallets = rpc.list_wallets().await?;
2055 /// println!("Available wallets: {:?}", wallets);
2056 /// # Ok(())
2057 /// # }
2058 /// ```
2059 pub async fn list_wallets(&self) -> Result<Vec<String>, AmpError> {
2060 tracing::debug!("Listing available wallets");
2061
2062 let params = serde_json::json!([]);
2063
2064 let wallets: Vec<String> = self
2065 .rpc_call("listwallets", params)
2066 .await
2067 .map_err(|e| e.with_context("Failed to list wallets"))?;
2068
2069 tracing::debug!("Found {} wallets", wallets.len());
2070 Ok(wallets)
2071 }
2072
2073 /// Sets up a watch-only wallet with the given address
2074 ///
2075 /// This is a convenience method that creates a watch-only wallet and imports the address
2076 ///
2077 /// # Arguments
2078 /// * `wallet_name` - Name of the wallet to create
2079 /// * `address` - Address to import as watch-only
2080 /// * `label` - Optional label for the address
2081 ///
2082 /// # Errors
2083 /// Returns an error if wallet creation or address import fails
2084 ///
2085 /// # Examples
2086 /// ```no_run
2087 /// # use amp_rs::ElementsRpc;
2088 /// # #[tokio::main]
2089 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2090 /// let rpc = ElementsRpc::from_env()?;
2091 /// rpc.setup_watch_only_wallet("test_wallet", "vjU8L4dKa1XyyVcPqKBbTgjT1tRC7qYp5VJGwndZSCFk4ntpWey1pQe6hcSGDMVurr9CsZ21EGsqGjWA", Some("treasury")).await?;
2092 /// # Ok(())
2093 /// # }
2094 /// ```
2095 #[allow(clippy::cognitive_complexity)]
2096 pub async fn setup_watch_only_wallet(
2097 &self,
2098 wallet_name: &str,
2099 address: &str,
2100 label: Option<&str>,
2101 ) -> Result<(), AmpError> {
2102 tracing::info!(
2103 "Setting up watch-only wallet '{}' with address: {}",
2104 wallet_name,
2105 address
2106 );
2107
2108 // Try the full wallet setup approach first
2109 match self
2110 .setup_wallet_with_address(wallet_name, address, label)
2111 .await
2112 {
2113 Ok(()) => {
2114 tracing::info!(
2115 "Successfully set up watch-only wallet '{}' with address: {}",
2116 wallet_name,
2117 address
2118 );
2119 return Ok(());
2120 }
2121 Err(e) => {
2122 tracing::warn!(
2123 "Full wallet setup failed: {}, trying direct address import",
2124 e
2125 );
2126 }
2127 }
2128
2129 // Fallback: Try to import the address directly without wallet operations
2130 match self.import_address_direct(address, label) {
2131 Ok(()) => {
2132 tracing::info!("Successfully imported address directly: {}", address);
2133 Ok(())
2134 }
2135 Err(e) => {
2136 tracing::error!(
2137 "Both wallet setup and direct import failed for address: {}",
2138 address
2139 );
2140 Err(AmpError::rpc(format!(
2141 "Failed to set up watch-only wallet or import address: wallet setup error: {e}, direct import error: {e}"
2142 )))
2143 }
2144 }
2145 }
2146
2147 /// Attempts to set up a wallet with address using the standard approach
2148 async fn setup_wallet_with_address(
2149 &self,
2150 wallet_name: &str,
2151 address: &str,
2152 label: Option<&str>,
2153 ) -> Result<(), AmpError> {
2154 // Try to create the wallet (will ignore if it already exists)
2155 self.create_wallet(wallet_name, true).await?;
2156
2157 // Try to load the wallet (will ignore if already loaded)
2158 self.load_wallet(wallet_name).await?;
2159
2160 // Import the address without rescanning (for faster setup)
2161 self.import_address(wallet_name, address, label, Some(false))
2162 .await?;
2163
2164 Ok(())
2165 }
2166
2167 /// Attempts to import an address directly without wallet operations (uses default wallet)
2168 #[allow(clippy::unused_self)]
2169 fn import_address_direct(&self, address: &str, _label: Option<&str>) -> Result<(), AmpError> {
2170 tracing::debug!("Attempting direct address import for: {}", address);
2171
2172 // This is a fallback method - we'll use empty string for wallet name to use default behavior
2173 // Note: This may not work as expected with the new signature, but kept for compatibility
2174 Err(AmpError::rpc(
2175 "Direct address import not supported with wallet-specific import_address".to_string(),
2176 ))
2177 }
2178
2179 /// Broadcasts a signed raw transaction to the network
2180 ///
2181 /// # Arguments
2182 /// * `hex` - The signed transaction in hexadecimal format
2183 ///
2184 /// # Errors
2185 /// Returns an error if the RPC call fails or transaction broadcast fails
2186 ///
2187 /// # Examples
2188 /// ```no_run
2189 /// # use amp_rs::ElementsRpc;
2190 /// # #[tokio::main]
2191 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2192 /// let rpc = ElementsRpc::from_env()?;
2193 /// let signed_tx_hex = "0200000000..."; // Signed transaction hex
2194 /// let txid = rpc.send_raw_transaction(signed_tx_hex).await?;
2195 /// println!("Transaction broadcast with ID: {}", txid);
2196 /// # Ok(())
2197 /// # }
2198 /// ```
2199 pub async fn send_raw_transaction(&self, hex: &str) -> Result<String, AmpError> {
2200 tracing::debug!(
2201 "Broadcasting raw transaction: {}",
2202 &hex[..std::cmp::min(hex.len(), 64)]
2203 );
2204
2205 let params = serde_json::json!([hex]);
2206
2207 let txid: String = self
2208 .rpc_call("sendrawtransaction", params)
2209 .await
2210 .map_err(|e| {
2211 tracing::error!("Raw transaction broadcast failed: {}", e);
2212 tracing::error!("Transaction hex (first 200 chars): {}", &hex[..std::cmp::min(hex.len(), 200)]);
2213
2214 // Provide specific guidance for blinding-related errors
2215 if e.to_string().contains("bad-txns-in-ne-out") || e.to_string().contains("value in != value out") {
2216 AmpError::rpc(format!(
2217 "Transaction broadcast failed due to confidential transaction blinding error. \
2218 This indicates that the blinding factors don't balance properly. \
2219 Possible solutions:\n\
2220 1. Ensure all addresses have proper blinding keys in the wallet\n\
2221 2. Verify that blindrawtransaction was called before signing\n\
2222 3. Check that UTXO blinding factors match between Elements and LWK\n\
2223 4. Try using unconfidential addresses for testing\n\
2224 Original error: {e}"
2225 ))
2226 } else {
2227 e.with_context("Failed to broadcast raw transaction")
2228 }
2229 })?;
2230
2231 tracing::info!("Successfully broadcast transaction with ID: {}", txid);
2232 Ok(txid)
2233 }
2234
2235 /// Retrieves detailed information about a transaction
2236 ///
2237 /// # Arguments
2238 /// * `txid` - The transaction ID to retrieve
2239 ///
2240 /// # Errors
2241 /// Returns an error if the RPC call fails or transaction is not found
2242 ///
2243 /// # Examples
2244 /// ```no_run
2245 /// # use amp_rs::ElementsRpc;
2246 /// # #[tokio::main]
2247 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2248 /// let rpc = ElementsRpc::from_env()?;
2249 /// let tx_detail = rpc.get_transaction("abc123...").await?;
2250 /// println!("Transaction has {} confirmations", tx_detail.confirmations);
2251 /// # Ok(())
2252 /// # }
2253 /// ```
2254 pub async fn get_transaction(&self, txid: &str) -> Result<TransactionDetail, AmpError> {
2255 tracing::debug!("Retrieving transaction details for: {}", txid);
2256
2257 let params = serde_json::json!([txid, true]); // true for verbose output
2258
2259 let tx_detail: TransactionDetail = self
2260 .rpc_call("gettransaction", params)
2261 .await
2262 .map_err(|e| e.with_context(format!("Failed to get transaction details for {txid}")))?;
2263
2264 tracing::debug!(
2265 "Retrieved transaction {} with {} confirmations",
2266 txid,
2267 tx_detail.confirmations
2268 );
2269
2270 Ok(tx_detail)
2271 }
2272
2273 /// Sends multiple outputs to multiple addresses using Elements' sendmany RPC
2274 ///
2275 /// This method uses Elements' built-in sendmany command which properly handles
2276 /// confidential transactions and blinding. This is the recommended approach for
2277 /// asset distribution as it avoids manual transaction construction issues.
2278 ///
2279 /// # Arguments
2280 /// * `wallet_name` - Name of the Elements wallet to use
2281 /// * `address_amounts` - Map of addresses to amounts to send
2282 /// * `asset_amounts` - Map of addresses to asset IDs for each output
2283 /// * `min_conf` - Minimum confirmations for inputs (default: 1)
2284 /// * `comment` - Optional transaction comment
2285 /// * `subtract_fee_from` - Optional addresses to subtract fees from
2286 /// * `replaceable` - Whether transaction is replaceable (default: false)
2287 /// * `conf_target` - Confirmation target for fee estimation (default: 1)
2288 /// * `estimate_mode` - Fee estimation mode (default: "UNSET")
2289 ///
2290 /// # Returns
2291 /// Returns the transaction ID of the sent transaction
2292 ///
2293 /// # Errors
2294 /// Returns an error if the RPC call fails or transaction creation fails
2295 ///
2296 /// # Examples
2297 /// ```no_run
2298 /// # use amp_rs::ElementsRpc;
2299 /// # use std::collections::HashMap;
2300 /// # #[tokio::main]
2301 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2302 /// let rpc = ElementsRpc::from_env()?;
2303 ///
2304 /// let mut address_amounts = HashMap::new();
2305 /// address_amounts.insert("address1".to_string(), 100.0);
2306 /// address_amounts.insert("address2".to_string(), 50.0);
2307 ///
2308 /// let mut asset_amounts = HashMap::new();
2309 /// asset_amounts.insert("address1".to_string(), "asset_id_hex".to_string());
2310 /// asset_amounts.insert("address2".to_string(), "asset_id_hex".to_string());
2311 ///
2312 /// let txid = rpc.sendmany("wallet_name", address_amounts, asset_amounts, None, None, None, None, None, None).await?;
2313 /// println!("Transaction sent with ID: {}", txid);
2314 /// # Ok(())
2315 /// # }
2316 /// ```
2317 #[allow(clippy::too_many_arguments, clippy::cognitive_complexity)]
2318 pub async fn sendmany(
2319 &self,
2320 wallet_name: &str,
2321 address_amounts: std::collections::HashMap<String, f64>,
2322 asset_amounts: std::collections::HashMap<String, String>,
2323 min_conf: Option<u32>,
2324 comment: Option<&str>,
2325 subtract_fee_from: Option<Vec<String>>,
2326 replaceable: Option<bool>,
2327 conf_target: Option<u32>,
2328 estimate_mode: Option<&str>,
2329 ) -> Result<String, AmpError> {
2330 tracing::debug!(
2331 "Sending to {} addresses using sendmany for wallet {}",
2332 address_amounts.len(),
2333 wallet_name
2334 );
2335
2336 // First load the wallet to ensure it's available
2337 self.load_wallet(wallet_name).await?;
2338
2339 // Elements sendmany parameters:
2340 // 1. dummy (empty string for compatibility)
2341 // 2. amounts (map of address -> amount)
2342 // 3. minconf (minimum confirmations, default 1)
2343 // 4. comment (optional comment)
2344 // 5. subtractfeefrom (array of addresses to subtract fee from)
2345 // 6. replaceable (boolean, default false)
2346 // 7. conf_target (confirmation target for fee estimation)
2347 // 8. estimate_mode (fee estimation mode)
2348 // 9. assetlabel (map of address -> asset_id for multi-asset sends)
2349 let params = serde_json::json!([
2350 "", // dummy (required for compatibility)
2351 address_amounts, // amounts map
2352 min_conf.unwrap_or(1), // minconf
2353 comment.unwrap_or(""), // comment
2354 subtract_fee_from.unwrap_or_default(), // subtractfeefrom
2355 replaceable.unwrap_or(false), // replaceable
2356 conf_target.unwrap_or(1), // conf_target
2357 estimate_mode.unwrap_or("UNSET"), // estimate_mode
2358 asset_amounts // assetlabel (asset map)
2359 ]);
2360
2361 // Use the wallet-specific RPC endpoint
2362 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
2363
2364 let request = RpcRequest {
2365 jsonrpc: "1.0".to_string(),
2366 id: "amp-client".to_string(),
2367 method: "sendmany".to_string(),
2368 params,
2369 };
2370
2371 tracing::debug!("Sendmany request parameters:");
2372 tracing::debug!(" wallet: {}", wallet_name);
2373 tracing::debug!(" address_amounts: {:?}", address_amounts);
2374 tracing::debug!(" asset_amounts: {:?}", asset_amounts);
2375
2376 let response = self
2377 .client
2378 .post(&wallet_url)
2379 .basic_auth(&self.username, Some(&self.password))
2380 .json(&request)
2381 .send()
2382 .await
2383 .map_err(|e| AmpError::rpc(format!("Failed to send sendmany RPC request: {e}")))?;
2384
2385 if !response.status().is_success() {
2386 let status = response.status();
2387 let error_body = response
2388 .text()
2389 .await
2390 .unwrap_or_else(|_| "Unable to read error body".to_string());
2391 return Err(AmpError::rpc(format!(
2392 "Sendmany RPC request failed with status: {status} - Body: {error_body}"
2393 )));
2394 }
2395
2396 let rpc_response: RpcResponse<String> = response
2397 .json()
2398 .await
2399 .map_err(|e| AmpError::rpc(format!("Failed to parse sendmany RPC response: {e}")))?;
2400
2401 if let Some(error) = rpc_response.error {
2402 return Err(AmpError::rpc(format!(
2403 "Sendmany RPC error: {} (code: {})",
2404 error.message, error.code
2405 )));
2406 }
2407
2408 let txid = rpc_response.result.unwrap_or_default();
2409 tracing::info!("Successfully sent transaction with sendmany: {}", txid);
2410 Ok(txid)
2411 }
2412
2413 /// Waits for blockchain confirmations with configurable timeout
2414 ///
2415 /// This method polls the blockchain every 15 seconds to check for transaction confirmations.
2416 /// It waits for a minimum number of confirmations (default 2) before returning successfully.
2417 /// The method includes a configurable timeout to prevent indefinite waiting.
2418 ///
2419 /// # Arguments
2420 /// * `txid` - The transaction ID to monitor for confirmations
2421 /// * `min_confirmations` - Minimum number of confirmations required (default: 2)
2422 /// * `timeout_minutes` - Timeout in minutes (default: 10)
2423 ///
2424 /// # Returns
2425 /// Returns the final `TransactionDetail` when sufficient confirmations are reached
2426 ///
2427 /// # Errors
2428 /// Returns `AmpError::Timeout` if the timeout is exceeded before confirmations are received
2429 /// Returns `AmpError::Rpc` if there are issues communicating with the Elements node
2430 ///
2431 /// # Examples
2432 /// ```no_run
2433 /// # use amp_rs::ElementsRpc;
2434 /// # #[tokio::main]
2435 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2436 /// let rpc = ElementsRpc::from_env()?;
2437 /// let tx_detail = rpc.wait_for_confirmations("abc123...", Some(2), Some(10)).await?;
2438 /// println!("Transaction confirmed with {} confirmations", tx_detail.confirmations);
2439 /// # Ok(())
2440 /// # }
2441 /// ```
2442 pub async fn wait_for_confirmations(
2443 &self,
2444 txid: &str,
2445 min_confirmations: Option<u32>,
2446 timeout_minutes: Option<u64>,
2447 ) -> Result<TransactionDetail, AmpError> {
2448 self.wait_for_confirmations_with_interval(txid, min_confirmations, timeout_minutes, None)
2449 .await
2450 }
2451
2452 /// Internal method for waiting for confirmations with configurable poll interval
2453 /// This is primarily used for testing to avoid long waits
2454 ///
2455 /// # Errors
2456 ///
2457 /// Returns an error if:
2458 /// - The timeout is exceeded before confirmations are received
2459 /// - There are issues communicating with the Elements node
2460 /// - The transaction cannot be found or is invalid
2461 #[allow(clippy::cognitive_complexity)]
2462 pub async fn wait_for_confirmations_with_interval(
2463 &self,
2464 txid: &str,
2465 min_confirmations: Option<u32>,
2466 timeout_minutes: Option<u64>,
2467 poll_interval_secs: Option<u64>,
2468 ) -> Result<TransactionDetail, AmpError> {
2469 let min_confirmations = min_confirmations.unwrap_or(2);
2470 let timeout_minutes = timeout_minutes.unwrap_or(10);
2471 let timeout_duration = if timeout_minutes == 0 {
2472 std::time::Duration::from_secs(3) // Minimum 3 seconds for testing
2473 } else {
2474 std::time::Duration::from_secs(timeout_minutes * 60)
2475 };
2476 let poll_interval = std::time::Duration::from_secs(poll_interval_secs.unwrap_or(15));
2477
2478 tracing::info!(
2479 "Starting confirmation monitoring for transaction {} (min_confirmations: {}, timeout: {} minutes)",
2480 txid,
2481 min_confirmations,
2482 timeout_minutes
2483 );
2484
2485 let start_time = std::time::Instant::now();
2486
2487 loop {
2488 // Check if we've exceeded the timeout
2489 if start_time.elapsed() >= timeout_duration {
2490 let error_msg = format!(
2491 "Timeout waiting for confirmations after {timeout_minutes} minutes. Transaction ID: {txid}. \
2492 You can retry confirmation by calling the confirmation API with this txid."
2493 );
2494 tracing::error!("{}", error_msg);
2495 return Err(AmpError::Timeout(error_msg));
2496 }
2497
2498 // Get current transaction details
2499 match self.get_transaction(txid).await {
2500 Ok(tx_detail) => {
2501 tracing::debug!(
2502 "Transaction {} has {} confirmations (need {})",
2503 txid,
2504 tx_detail.confirmations,
2505 min_confirmations
2506 );
2507
2508 if tx_detail.confirmations >= min_confirmations {
2509 tracing::info!(
2510 "Transaction {} confirmed with {} confirmations",
2511 txid,
2512 tx_detail.confirmations
2513 );
2514 return Ok(tx_detail);
2515 }
2516
2517 // Log progress every few polls to avoid spam
2518 if start_time.elapsed().as_secs() % 60 < 15 {
2519 tracing::info!(
2520 "Waiting for confirmations: {}/{} (elapsed: {}s)",
2521 tx_detail.confirmations,
2522 min_confirmations,
2523 start_time.elapsed().as_secs()
2524 );
2525 }
2526 }
2527 Err(e) => {
2528 tracing::warn!(
2529 "Failed to get transaction details for {}: {}. Retrying in {} seconds...",
2530 txid,
2531 e,
2532 poll_interval.as_secs()
2533 );
2534 // Continue polling even if individual calls fail, as the transaction
2535 // might not be visible immediately after broadcasting
2536 }
2537 }
2538
2539 // Wait before next poll
2540 tokio::time::sleep(poll_interval).await;
2541 }
2542 }
2543
2544 /// Selects appropriate UTXOs to cover the required amount plus fees
2545 ///
2546 /// This method implements a simple UTXO selection algorithm that:
2547 /// 1. Filters UTXOs by asset ID and spendability
2548 /// 2. Sorts UTXOs by amount (largest first) for efficiency
2549 /// 3. Selects UTXOs until the target amount plus estimated fees is covered
2550 ///
2551 /// # Arguments
2552 /// * `asset_id` - The asset ID to select UTXOs for
2553 /// * `target_amount` - The total amount needed for distribution
2554 /// * `estimated_fee` - Estimated transaction fee in the same asset
2555 ///
2556 /// # Returns
2557 /// Returns a tuple of (`selected_utxos`, `total_selected_amount`)
2558 ///
2559 /// # Errors
2560 /// Returns an error if insufficient UTXOs are available or RPC calls fail
2561 ///
2562 /// # Examples
2563 /// ```no_run
2564 /// # use amp_rs::ElementsRpc;
2565 /// # #[tokio::main]
2566 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2567 /// let rpc = ElementsRpc::from_env()?;
2568 /// let (selected_utxos, total_amount) = rpc.select_utxos_for_amount(
2569 /// "wallet_name",
2570 /// "asset_id_hex",
2571 /// 150.0,
2572 /// 0.001
2573 /// ).await?;
2574 /// println!("Selected {} UTXOs totaling {}", selected_utxos.len(), total_amount);
2575 /// # Ok(())
2576 /// # }
2577 /// ```
2578 pub async fn select_utxos_for_amount(
2579 &self,
2580 wallet_name: &str,
2581 asset_id: &str,
2582 target_amount: f64,
2583 estimated_fee: f64,
2584 ) -> Result<(Vec<Unspent>, f64), AmpError> {
2585 tracing::debug!(
2586 "Selecting UTXOs for asset {} from wallet {} - target: {}, fee: {}",
2587 asset_id,
2588 wallet_name,
2589 target_amount,
2590 estimated_fee
2591 );
2592
2593 // Get all UTXOs for this asset from the specified wallet
2594 let mut utxos = self
2595 .list_unspent_for_wallet(wallet_name, Some(asset_id))
2596 .await?;
2597
2598 // Filter for spendable UTXOs only
2599 utxos.retain(|utxo| utxo.spendable && utxo.asset == asset_id);
2600
2601 if utxos.is_empty() {
2602 return Err(AmpError::validation(format!(
2603 "No spendable UTXOs found for asset {asset_id}. \
2604 This typically means:\n\
2605 1. The treasury address is not imported in the Elements node as a watch-only address\n\
2606 2. The asset issuance transaction hasn't been confirmed yet\n\
2607 3. The UTXOs have already been spent\n\
2608 \n\
2609 To fix this:\n\
2610 - Ensure the treasury address is imported: `elements-cli importaddress <treasury_address> treasury false`\n\
2611 - Wait for the asset issuance transaction to be confirmed\n\
2612 - Check that the treasury address matches the one used for asset issuance"
2613 )));
2614 }
2615
2616 // Sort UTXOs by amount (largest first) for efficient selection
2617 utxos.sort_by(|a, b| {
2618 b.amount
2619 .partial_cmp(&a.amount)
2620 .unwrap_or(std::cmp::Ordering::Equal)
2621 });
2622
2623 let required_amount = target_amount + estimated_fee;
2624 let mut selected_utxos = Vec::new();
2625 let mut total_selected = 0.0;
2626
2627 // Select UTXOs until we have enough to cover the required amount
2628 for utxo in utxos {
2629 selected_utxos.push(utxo.clone());
2630 total_selected += utxo.amount;
2631
2632 if total_selected >= required_amount {
2633 break;
2634 }
2635 }
2636
2637 // Check if we have sufficient funds
2638 if total_selected < required_amount {
2639 return Err(AmpError::validation(format!(
2640 "Insufficient UTXOs: need {required_amount}, have {total_selected} (target: {target_amount}, fee: {estimated_fee})"
2641 )));
2642 }
2643
2644 tracing::info!(
2645 "Selected {} UTXOs totaling {} for target {} + fee {}",
2646 selected_utxos.len(),
2647 total_selected,
2648 target_amount,
2649 estimated_fee
2650 );
2651
2652 Ok((selected_utxos, total_selected))
2653 }
2654
2655 /// Builds a raw transaction for asset distribution with proper change handling
2656 ///
2657 /// This method orchestrates the complete transaction building process:
2658 /// 1. Selects appropriate UTXOs using `select_utxos_for_amount`
2659 /// 2. Creates transaction inputs from selected UTXOs
2660 /// 3. Creates outputs for distribution addresses
2661 /// 4. Calculates and creates change output if necessary
2662 /// 5. Builds the raw transaction using `create_raw_transaction`
2663 ///
2664 /// # Arguments
2665 /// * `asset_id` - The asset ID being distributed
2666 /// * `address_amounts` - Map of recipient addresses to amounts
2667 /// * `change_address` - Address to send change to (if any)
2668 /// * `estimated_fee` - Estimated transaction fee
2669 ///
2670 /// # Returns
2671 /// Returns a tuple of (`raw_transaction_hex`, `selected_utxos`, `change_amount`)
2672 ///
2673 /// # Errors
2674 /// Returns an error if UTXO selection fails or transaction building fails
2675 ///
2676 /// # Examples
2677 /// ```no_run
2678 /// # use amp_rs::ElementsRpc;
2679 /// # use std::collections::HashMap;
2680 /// # #[tokio::main]
2681 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2682 /// let rpc = ElementsRpc::from_env()?;
2683 /// let mut address_amounts = HashMap::new();
2684 /// address_amounts.insert("address1".to_string(), 100.0);
2685 /// address_amounts.insert("address2".to_string(), 50.0);
2686 ///
2687 /// let (raw_tx, utxos, change) = rpc.build_distribution_transaction(
2688 /// "wallet_name",
2689 /// "asset_id_hex",
2690 /// address_amounts,
2691 /// "change_address",
2692 /// 0.001
2693 /// ).await?;
2694 /// println!("Built transaction with {} inputs, change: {}", utxos.len(), change);
2695 /// # Ok(())
2696 /// # }
2697 /// ```
2698 #[allow(clippy::cognitive_complexity)]
2699 #[allow(clippy::too_many_lines)]
2700 pub async fn build_distribution_transaction(
2701 &self,
2702 wallet_name: &str,
2703 asset_id: &str,
2704 address_amounts: std::collections::HashMap<String, f64>,
2705 change_address: &str,
2706 _estimated_fee: f64,
2707 ) -> Result<(String, Vec<Unspent>, f64), AmpError> {
2708 const DUST_THRESHOLD: f64 = 0.00001;
2709 const LBTC_ASSET_ID: &str =
2710 "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; // L-BTC on Liquid testnet
2711
2712 tracing::debug!(
2713 "Building distribution transaction for asset {} with {} outputs",
2714 asset_id,
2715 address_amounts.len()
2716 );
2717
2718 // Calculate total distribution amount
2719 let total_distribution: f64 = address_amounts.values().sum();
2720
2721 if total_distribution <= 0.0 {
2722 return Err(AmpError::validation(
2723 "Total distribution amount must be greater than zero".to_string(),
2724 ));
2725 }
2726
2727 // Select UTXOs to cover the distribution (custom asset)
2728 let (selected_asset_utxos, total_selected) = self
2729 .select_utxos_for_amount(wallet_name, asset_id, total_distribution, 0.0)
2730 .await?;
2731
2732 // Also select L-BTC UTXOs for transaction fees
2733 // Elements requires L-BTC inputs for fees even when distributing custom assets
2734 let min_lbtc_fee = 0.00001; // Minimum L-BTC needed for fees
2735 let (selected_lbtc_utxos, lbtc_total) = match self
2736 .select_utxos_for_amount(wallet_name, LBTC_ASSET_ID, 0.0, min_lbtc_fee)
2737 .await
2738 {
2739 Ok((utxos, total)) => {
2740 tracing::info!(
2741 "Selected {} L-BTC UTXOs totaling {} for fees",
2742 utxos.len(),
2743 total
2744 );
2745 (utxos, total)
2746 }
2747 Err(e) => {
2748 tracing::warn!(
2749 "Could not select L-BTC UTXOs for fees: {}. Transaction may fail.",
2750 e
2751 );
2752 (Vec::new(), 0.0)
2753 }
2754 };
2755
2756 // Combine custom asset UTXOs and L-BTC UTXOs
2757 let mut all_utxos = selected_asset_utxos.clone();
2758 all_utxos.extend(selected_lbtc_utxos.clone());
2759
2760 if selected_lbtc_utxos.is_empty() {
2761 tracing::warn!(
2762 "No L-BTC UTXOs selected for fees. Transaction may fail during broadcast."
2763 );
2764 } else {
2765 tracing::info!(
2766 "Transaction includes {} custom asset UTXOs and {} L-BTC UTXOs for fees",
2767 selected_asset_utxos.len(),
2768 selected_lbtc_utxos.len()
2769 );
2770 }
2771
2772 // Create transaction inputs from all selected UTXOs
2773 let inputs: Vec<TxInput> = all_utxos
2774 .iter()
2775 .map(|utxo| TxInput {
2776 txid: utxo.txid.clone(),
2777 vout: utxo.vout,
2778 sequence: None, // Use default sequence
2779 })
2780 .collect();
2781
2782 // Create outputs for distribution (custom asset)
2783 // We need to track outputs as a vector since we may have multiple outputs to the same address
2784 // (e.g., custom asset change + L-BTC change to the same change address)
2785 let mut output_list = Vec::new();
2786
2787 // Add distribution outputs (custom asset)
2788 for (address, amount) in &address_amounts {
2789 output_list.push((address.clone(), *amount, asset_id.to_string()));
2790 }
2791
2792 // Calculate change amount for custom asset (total selected - distribution)
2793 let asset_change_amount = total_selected - total_distribution;
2794
2795 // Add asset change output if there's a significant amount left
2796 if asset_change_amount > DUST_THRESHOLD {
2797 output_list.push((
2798 change_address.to_string(),
2799 asset_change_amount,
2800 asset_id.to_string(),
2801 ));
2802
2803 tracing::debug!(
2804 "Adding asset change output: {} {} to address {}",
2805 asset_change_amount,
2806 asset_id,
2807 change_address
2808 );
2809 } else if asset_change_amount > 0.0 {
2810 tracing::warn!(
2811 "Asset change amount {} is below dust threshold {}, will be lost",
2812 asset_change_amount,
2813 DUST_THRESHOLD
2814 );
2815 }
2816
2817 // Handle L-BTC change if we selected L-BTC UTXOs for fees
2818 // In Elements, the fee is implicit - it's the difference between L-BTC inputs and outputs
2819 // We should NOT subtract the fee from outputs; Elements calculates it automatically
2820 if !selected_lbtc_utxos.is_empty() {
2821 tracing::debug!(
2822 "L-BTC input total: {}, minimum fee needed: {}",
2823 lbtc_total,
2824 min_lbtc_fee
2825 );
2826
2827 // Check if we have enough L-BTC for the minimum fee
2828 if lbtc_total < min_lbtc_fee {
2829 return Err(AmpError::validation(format!(
2830 "Insufficient L-BTC for fees: have {lbtc_total}, need at least {min_lbtc_fee}"
2831 )));
2832 }
2833
2834 // For now, let's try NOT adding any L-BTC change output
2835 // and let Elements handle the fee automatically from the input/output difference
2836 tracing::info!(
2837 "Using L-BTC input {} for fees - no explicit L-BTC change output (Elements will handle fee automatically)",
2838 lbtc_total
2839 );
2840
2841 // Note: If this approach works, the entire L-BTC input will become the fee
2842 // If we need change, we'll need to figure out the correct way to handle it
2843 }
2844
2845 // For confidential addresses, we need to import them into the wallet first
2846 // so Elements knows about the blinding keys
2847 for address in address_amounts.keys() {
2848 if address.starts_with('v') {
2849 // Confidential address
2850 tracing::debug!("Importing confidential address into wallet: {}", address);
2851 if let Err(e) = self
2852 .import_address_to_wallet(wallet_name, address, None, false)
2853 .await
2854 {
2855 tracing::warn!("Failed to import confidential address {}: {}", address, e);
2856 // Continue anyway - the address might already be imported
2857 }
2858 }
2859 }
2860
2861 // Build the raw transaction using wallet-specific endpoint for confidential transactions
2862 // For confidential transactions, we need to use blindrawtransaction to properly handle blinding
2863 let raw_transaction = self
2864 .create_raw_transaction_with_outputs(wallet_name, inputs, output_list)
2865 .await
2866 .map_err(|e| {
2867 // Provide more helpful error message for the common L-BTC fee issue
2868 if e.to_string().contains("bad-txns-in-ne-out") || e.to_string().contains("value in != value out") {
2869 AmpError::validation(format!(
2870 "Transaction failed due to confidential transaction blinding mismatch. \
2871 This occurs when Elements creates blinding factors that don't match LWK's expectations. \
2872 To fix this:\n\
2873 1. Ensure the wallet has proper blinding keys for all addresses\n\
2874 2. Use blindrawtransaction before signing\n\
2875 3. Verify UTXO blinding factors match between Elements and LWK\n\
2876 4. Original error: {e}"
2877 ))
2878 } else {
2879 e.with_context("Failed to build distribution transaction")
2880 }
2881 })?;
2882
2883 // For confidential transactions, we need to blind the transaction properly
2884 // This ensures the blinding factors are compatible with LWK signing
2885 tracing::debug!("Blinding raw transaction for confidential asset distribution");
2886 let blinded_transaction = self
2887 .blind_raw_transaction(wallet_name, &raw_transaction)
2888 .await
2889 .map_err(|e| {
2890 tracing::warn!(
2891 "Failed to blind transaction, proceeding with unblinded: {}",
2892 e
2893 );
2894 // If blinding fails, we'll try to proceed with the unblinded transaction
2895 // This might work for some cases but could fail during broadcast
2896 e.with_context("Transaction blinding failed")
2897 })
2898 .unwrap_or_else(|_| {
2899 tracing::warn!("Using unblinded transaction - this may cause broadcast failures");
2900 raw_transaction.clone()
2901 });
2902
2903 tracing::info!(
2904 "Built distribution transaction: {} inputs, {} outputs, asset change: {}",
2905 all_utxos.len(),
2906 address_amounts.len() + usize::from(asset_change_amount > DUST_THRESHOLD),
2907 if asset_change_amount > DUST_THRESHOLD {
2908 asset_change_amount
2909 } else {
2910 0.0
2911 }
2912 );
2913
2914 Ok((blinded_transaction, all_utxos, asset_change_amount))
2915 }
2916
2917 /// Creates a raw transaction with multiple outputs that can handle multiple assets to the same address
2918 ///
2919 /// This method is similar to `create_raw_transaction_with_wallet` but handles the case where
2920 /// multiple outputs with different assets need to go to the same address (e.g., asset change + L-BTC change).
2921 ///
2922 /// # Arguments
2923 /// * `wallet_name` - Name of the Elements wallet to use
2924 /// * `inputs` - Vector of transaction inputs
2925 /// * `outputs` - Vector of (address, amount, `asset_id`) tuples
2926 ///
2927 /// # Returns
2928 /// Returns the raw transaction hex string
2929 #[allow(clippy::cognitive_complexity)]
2930 async fn create_raw_transaction_with_outputs(
2931 &self,
2932 wallet_name: &str,
2933 inputs: Vec<TxInput>,
2934 outputs: Vec<(String, f64, String)>, // (address, amount, asset_id)
2935 ) -> Result<String, AmpError> {
2936 tracing::debug!(
2937 "Creating raw transaction with wallet {} - {} inputs and {} outputs",
2938 wallet_name,
2939 inputs.len(),
2940 outputs.len()
2941 );
2942
2943 // First load the wallet to ensure it's available
2944 self.load_wallet(wallet_name).await?;
2945
2946 // Elements RPC createrawtransaction expects outputs as an array of objects
2947 // Each output object should contain both address, amount, and asset
2948 let mut outputs_array = Vec::new();
2949
2950 for (address, amount, asset_id) in &outputs {
2951 // Convert amount to string with proper precision for Elements
2952 let amount_str = format!("{amount:.8}");
2953
2954 outputs_array.push(serde_json::json!({
2955 address.clone(): amount_str,
2956 "asset": asset_id
2957 }));
2958 }
2959
2960 let params = serde_json::json!([
2961 inputs, // inputs as TxInput array
2962 outputs_array, // outputs as array of {address: amount, asset: id} objects
2963 0, // locktime (0 = no locktime)
2964 false, // replaceable (false = not replaceable)
2965 ]);
2966
2967 // Debug: Log the exact parameters being sent to createrawtransaction
2968 tracing::error!("createrawtransaction parameters (wallet-specific, corrected format):");
2969 tracing::error!(" wallet: {}", wallet_name);
2970 tracing::error!(
2971 " inputs: {}",
2972 serde_json::to_string_pretty(&inputs).unwrap_or_default()
2973 );
2974 tracing::error!(
2975 " outputs_array: {}",
2976 serde_json::to_string_pretty(&outputs_array).unwrap_or_default()
2977 );
2978
2979 // Use the wallet-specific RPC endpoint
2980 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
2981
2982 let request = RpcRequest {
2983 jsonrpc: "1.0".to_string(),
2984 id: "amp-client".to_string(),
2985 method: "createrawtransaction".to_string(),
2986 params,
2987 };
2988
2989 let response = self
2990 .client
2991 .post(&wallet_url)
2992 .basic_auth(&self.username, Some(&self.password))
2993 .json(&request)
2994 .send()
2995 .await
2996 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
2997
2998 if !response.status().is_success() {
2999 let status = response.status();
3000 let error_body = response
3001 .text()
3002 .await
3003 .unwrap_or_else(|_| "Unable to read error body".to_string());
3004 return Err(AmpError::rpc(format!(
3005 "RPC request failed with status: {status} - Body: {error_body}"
3006 )));
3007 }
3008
3009 let rpc_response: RpcResponse<String> = response
3010 .json()
3011 .await
3012 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
3013
3014 if let Some(error) = rpc_response.error {
3015 return Err(AmpError::rpc(format!(
3016 "RPC error creating raw transaction: {} (code: {})",
3017 error.message, error.code
3018 )));
3019 }
3020
3021 Ok(rpc_response.result.unwrap_or_default())
3022 }
3023
3024 /// Blinds a raw transaction for confidential transactions
3025 ///
3026 /// This method uses Elements' blindrawtransaction RPC to properly blind a transaction
3027 /// for confidential asset transfers. This is crucial for Liquid transactions to ensure
3028 /// the blinding factors are properly balanced.
3029 ///
3030 /// # Arguments
3031 /// * `wallet_name` - Name of the Elements wallet to use for blinding
3032 /// * `raw_transaction` - The raw transaction hex to blind
3033 ///
3034 /// # Returns
3035 /// Returns the blinded transaction hex string
3036 ///
3037 /// # Errors
3038 /// Returns an error if the RPC call fails or blinding is not possible
3039 pub async fn blind_raw_transaction(
3040 &self,
3041 wallet_name: &str,
3042 raw_transaction: &str,
3043 ) -> Result<String, AmpError> {
3044 tracing::debug!(
3045 "Blinding raw transaction for wallet {} - tx length: {} chars",
3046 wallet_name,
3047 raw_transaction.len()
3048 );
3049
3050 // First load the wallet to ensure it's available
3051 self.load_wallet(wallet_name).await?;
3052
3053 // Elements blindrawtransaction parameters:
3054 // 1. Raw transaction hex
3055 // 2. Input blinding data (can be empty array for auto-detection)
3056 // 3. Input amounts (can be empty array for auto-detection from UTXOs)
3057 // 4. Input assets (can be empty array for auto-detection from UTXOs)
3058 // 5. Input asset blinders (can be empty array for auto-detection)
3059 // 6. Input amount blinders (can be empty array for auto-detection)
3060 let params = serde_json::json!([
3061 raw_transaction, // Raw transaction hex
3062 [], // Input blinding data (empty for auto-detection)
3063 [], // Input amounts (empty for auto-detection)
3064 [], // Input assets (empty for auto-detection)
3065 [], // Input asset blinders (empty for auto-detection)
3066 [] // Input amount blinders (empty for auto-detection)
3067 ]);
3068
3069 // Use the wallet-specific RPC endpoint
3070 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3071
3072 let request = RpcRequest {
3073 jsonrpc: "1.0".to_string(),
3074 id: "amp-client".to_string(),
3075 method: "blindrawtransaction".to_string(),
3076 params,
3077 };
3078
3079 let response = self
3080 .client
3081 .post(&wallet_url)
3082 .basic_auth(&self.username, Some(&self.password))
3083 .json(&request)
3084 .send()
3085 .await
3086 .map_err(|e| {
3087 AmpError::rpc(format!("Failed to send blindrawtransaction request: {e}"))
3088 })?;
3089
3090 if !response.status().is_success() {
3091 let status = response.status();
3092 let error_body = response
3093 .text()
3094 .await
3095 .unwrap_or_else(|_| "Unable to read error body".to_string());
3096 return Err(AmpError::rpc(format!(
3097 "blindrawtransaction failed with status: {status} - Body: {error_body}"
3098 )));
3099 }
3100
3101 let rpc_response: RpcResponse<String> = response.json().await.map_err(|e| {
3102 AmpError::rpc(format!("Failed to parse blindrawtransaction response: {e}"))
3103 })?;
3104
3105 if let Some(error) = rpc_response.error {
3106 return Err(AmpError::rpc(format!(
3107 "RPC error blinding transaction: {} (code: {})",
3108 error.message, error.code
3109 )));
3110 }
3111
3112 let blinded_tx = rpc_response.result.unwrap_or_default();
3113
3114 tracing::info!(
3115 "Successfully blinded transaction - original: {} chars, blinded: {} chars",
3116 raw_transaction.len(),
3117 blinded_tx.len()
3118 );
3119
3120 Ok(blinded_tx)
3121 }
3122
3123 /// Signs a raw transaction using the provided signer callback
3124 ///
3125 /// This method integrates with the Signer trait to sign unsigned transactions.
3126 /// It handles the complete signing workflow including:
3127 /// 1. Validation of the unsigned transaction hex format
3128 /// 2. Calling the signer's `sign_transaction` method
3129 /// 3. Validation of the signed transaction format and structure
3130 /// 4. Proper error handling and context propagation
3131 ///
3132 /// # Arguments
3133 /// * `unsigned_tx_hex` - The unsigned transaction in hexadecimal format
3134 /// * `signer` - Implementation of the Signer trait for transaction signing
3135 ///
3136 /// # Returns
3137 /// Returns the signed transaction as a hex string
3138 ///
3139 /// # Errors
3140 /// Returns an error if:
3141 /// - The unsigned transaction hex is invalid or malformed
3142 /// - The signer fails to sign the transaction
3143 /// - The signed transaction format is invalid
3144 /// - Any validation checks fail
3145 ///
3146 /// # Examples
3147 /// ```no_run
3148 /// # use amp_rs::{ElementsRpc, signer::{Signer, LwkSoftwareSigner}};
3149 /// # #[tokio::main]
3150 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3151 /// let rpc = ElementsRpc::from_env()?;
3152 /// let (_, signer) = LwkSoftwareSigner::generate_new()?;
3153 /// let unsigned_tx = "020000000001..."; // Unsigned transaction hex
3154 /// let signed_tx = rpc.sign_transaction(unsigned_tx, &signer).await?;
3155 /// println!("Transaction signed successfully: {}", signed_tx);
3156 /// # Ok(())
3157 /// # }
3158 /// ```
3159 #[allow(clippy::cognitive_complexity)]
3160 pub async fn sign_transaction(
3161 &self,
3162 unsigned_tx_hex: &str,
3163 signer: &dyn crate::signer::Signer,
3164 ) -> Result<String, AmpError> {
3165 const MIN_TX_SIZE: usize = 10; // Minimum bytes for a valid transaction
3166
3167 tracing::debug!(
3168 "Signing transaction: {}...",
3169 &unsigned_tx_hex[..std::cmp::min(unsigned_tx_hex.len(), 64)]
3170 );
3171
3172 // Validate unsigned transaction hex format
3173 if unsigned_tx_hex.is_empty() {
3174 return Err(AmpError::validation(
3175 "Unsigned transaction hex cannot be empty".to_string(),
3176 ));
3177 }
3178
3179 // Check if hex string has valid format (even length, valid hex characters)
3180 if unsigned_tx_hex.len() % 2 != 0 {
3181 return Err(AmpError::validation(
3182 "Unsigned transaction hex must have even length".to_string(),
3183 ));
3184 }
3185
3186 // Validate hex characters
3187 if !unsigned_tx_hex.chars().all(|c| c.is_ascii_hexdigit()) {
3188 return Err(AmpError::validation(
3189 "Unsigned transaction contains invalid hex characters".to_string(),
3190 ));
3191 }
3192
3193 // Attempt to decode hex to validate transaction structure
3194 let tx_bytes = hex::decode(unsigned_tx_hex).map_err(|e| {
3195 AmpError::validation(format!("Failed to decode unsigned transaction hex: {e}"))
3196 })?;
3197
3198 tracing::debug!("Unsigned transaction validation passed, calling signer");
3199
3200 // Call the signer to sign the transaction
3201 let signed_tx_hex = signer
3202 .sign_transaction(unsigned_tx_hex)
3203 .await
3204 .map_err(|e| {
3205 tracing::error!("Transaction signing failed: {}", e);
3206 AmpError::Signer(e).with_context("Failed to sign transaction")
3207 })?;
3208
3209 tracing::debug!(
3210 "Signer returned signed transaction: {}...",
3211 &signed_tx_hex[..std::cmp::min(signed_tx_hex.len(), 64)]
3212 );
3213
3214 // Validate signed transaction format and structure
3215 if signed_tx_hex.is_empty() {
3216 return Err(AmpError::validation(
3217 "Signed transaction hex cannot be empty".to_string(),
3218 ));
3219 }
3220
3221 // Check if signed transaction has valid hex format
3222 if signed_tx_hex.len() % 2 != 0 {
3223 return Err(AmpError::validation(
3224 "Signed transaction hex must have even length".to_string(),
3225 ));
3226 }
3227
3228 // Validate hex characters in signed transaction
3229 if !signed_tx_hex.chars().all(|c| c.is_ascii_hexdigit()) {
3230 return Err(AmpError::validation(
3231 "Signed transaction contains invalid hex characters".to_string(),
3232 ));
3233 }
3234
3235 // Attempt to decode signed transaction to validate structure
3236 let signed_tx_bytes = hex::decode(&signed_tx_hex).map_err(|e| {
3237 AmpError::validation(format!("Failed to decode signed transaction hex: {e}"))
3238 })?;
3239
3240 // Basic validation: signed transaction should be at least as long as unsigned
3241 // (signatures add data, so signed tx should be larger or equal)
3242 if signed_tx_bytes.len() < tx_bytes.len() {
3243 return Err(AmpError::validation(
3244 "Signed transaction is shorter than unsigned transaction, which is invalid"
3245 .to_string(),
3246 ));
3247 }
3248
3249 // Additional validation: check that the transaction structure is reasonable
3250 // Minimum transaction size for Elements (very basic check)
3251 if signed_tx_bytes.len() < MIN_TX_SIZE {
3252 return Err(AmpError::validation(format!(
3253 "Signed transaction does not meet minimum size ({} bytes), minimum is {} bytes",
3254 signed_tx_bytes.len(),
3255 MIN_TX_SIZE
3256 )));
3257 }
3258
3259 tracing::info!(
3260 "Transaction signed successfully - unsigned: {} bytes, signed: {} bytes",
3261 tx_bytes.len(),
3262 signed_tx_bytes.len()
3263 );
3264
3265 Ok(signed_tx_hex)
3266 }
3267
3268 /// Signs and broadcasts a transaction in a single operation
3269 ///
3270 /// This is a convenience method that combines transaction signing and broadcasting.
3271 /// It performs the complete workflow of signing an unsigned transaction and
3272 /// immediately broadcasting it to the network.
3273 ///
3274 /// # Arguments
3275 /// * `unsigned_tx_hex` - The unsigned transaction in hexadecimal format
3276 /// * `signer` - Implementation of the Signer trait for transaction signing
3277 ///
3278 /// # Returns
3279 /// Returns the transaction ID of the broadcast transaction
3280 ///
3281 /// # Errors
3282 /// Returns an error if signing or broadcasting fails
3283 ///
3284 /// # Examples
3285 /// ```no_run
3286 /// # use amp_rs::{ElementsRpc, signer::{Signer, LwkSoftwareSigner}};
3287 /// # #[tokio::main]
3288 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3289 /// let rpc = ElementsRpc::from_env()?;
3290 /// let (_, signer) = LwkSoftwareSigner::generate_new()?;
3291 /// let unsigned_tx = "020000000001..."; // Unsigned transaction hex
3292 /// let txid = rpc.sign_and_broadcast_transaction(unsigned_tx, &signer).await?;
3293 /// println!("Transaction broadcast with ID: {}", txid);
3294 /// # Ok(())
3295 /// # }
3296 /// ```
3297 pub async fn sign_and_broadcast_transaction(
3298 &self,
3299 unsigned_tx_hex: &str,
3300 signer: &dyn crate::signer::Signer,
3301 ) -> Result<String, AmpError> {
3302 tracing::info!("Signing and broadcasting transaction");
3303
3304 // Sign the transaction
3305 let signed_tx_hex = self
3306 .sign_transaction(unsigned_tx_hex, signer)
3307 .await
3308 .map_err(|e| e.with_context("Failed during transaction signing phase"))?;
3309
3310 // Broadcast the signed transaction
3311 let txid = self
3312 .send_raw_transaction(&signed_tx_hex)
3313 .await
3314 .map_err(|e| e.with_context("Failed during transaction broadcast phase"))?;
3315
3316 tracing::info!("Successfully signed and broadcast transaction: {}", txid);
3317 Ok(txid)
3318 }
3319
3320 /// Signs and broadcasts a transaction with UTXO information for proper PSBT construction
3321 ///
3322 /// This method provides UTXO information to the signer for proper PSBT construction,
3323 /// which is required for confidential transactions where the signer needs to know
3324 /// the previous transaction outputs being spent.
3325 ///
3326 /// # Arguments
3327 /// * `unsigned_tx_hex` - The unsigned transaction in hexadecimal format
3328 /// * `utxos` - Vector of UTXOs being spent in the transaction
3329 /// * `signer` - Implementation of the Signer trait for transaction signing
3330 ///
3331 /// # Returns
3332 /// Returns the transaction ID of the broadcast transaction
3333 ///
3334 /// # Errors
3335 /// Returns an error if signing or broadcasting fails
3336 #[allow(clippy::cognitive_complexity)]
3337 pub async fn sign_and_broadcast_transaction_with_utxos(
3338 &self,
3339 unsigned_tx_hex: &str,
3340 utxos: &[Unspent],
3341 signer: &dyn crate::signer::Signer,
3342 ) -> Result<String, AmpError> {
3343 tracing::info!(
3344 "Signing and broadcasting transaction with {} UTXOs",
3345 utxos.len()
3346 );
3347
3348 // Try to use the enhanced signing method if the signer supports it
3349 let signed_tx_hex = if let Some(lwk_signer) = signer
3350 .as_any()
3351 .downcast_ref::<crate::signer::LwkSoftwareSigner>(
3352 ) {
3353 // Use the enhanced signing method with UTXO information
3354 tracing::debug!("Using LWK signer with UTXO information");
3355 lwk_signer
3356 .sign_transaction_with_utxos(unsigned_tx_hex, utxos)
3357 .await
3358 .map_err(|e| {
3359 AmpError::Signer(e)
3360 .with_context("Failed during enhanced transaction signing phase")
3361 })?
3362 } else {
3363 // Fall back to standard signing method
3364 tracing::debug!("Using standard signing method (no UTXO information)");
3365 self.sign_transaction(unsigned_tx_hex, signer)
3366 .await
3367 .map_err(|e| e.with_context("Failed during transaction signing phase"))?
3368 };
3369
3370 // Broadcast the signed transaction
3371 let txid = self
3372 .send_raw_transaction(&signed_tx_hex)
3373 .await
3374 .map_err(|e| e.with_context("Failed during transaction broadcast phase"))?;
3375
3376 tracing::info!("Successfully signed and broadcast transaction: {}", txid);
3377 Ok(txid)
3378 }
3379
3380 /// Collects change data from a confirmed transaction for distribution confirmation
3381 ///
3382 /// This method queries the Elements node to find change UTXOs from a specific transaction
3383 /// that belong to the specified asset. It's used after a distribution transaction is
3384 /// confirmed to collect the change outputs for the final confirmation API call.
3385 ///
3386 /// # Arguments
3387 /// * `asset_id` - The asset ID to filter change UTXOs for
3388 /// * `txid` - The transaction ID to filter change UTXOs from
3389 ///
3390 /// # Returns
3391 /// Returns a vector of Unspent UTXOs that represent change outputs from the transaction.
3392 /// Returns an empty vector if no change outputs exist for the specified asset and transaction.
3393 ///
3394 /// # Errors
3395 /// Returns an error if the RPC call fails or if there are issues querying the Elements node
3396 ///
3397 /// # Examples
3398 /// ```no_run
3399 /// # use amp_rs::ElementsRpc;
3400 /// # #[tokio::main]
3401 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3402 /// let rpc = ElementsRpc::from_env()?;
3403 /// let change_data = rpc.collect_change_data(
3404 /// "asset_id_hex",
3405 /// "transaction_id_hex",
3406 /// &rpc,
3407 /// "wallet_name"
3408 /// ).await?;
3409 ///
3410 /// if change_data.is_empty() {
3411 /// println!("No change outputs found for this transaction");
3412 /// } else {
3413 /// println!("Found {} change outputs", change_data.len());
3414 /// }
3415 /// # Ok(())
3416 /// # }
3417 /// ```
3418 #[allow(clippy::cognitive_complexity)]
3419 pub async fn collect_change_data(
3420 &self,
3421 asset_id: &str,
3422 txid: &str,
3423 node_rpc: &Self,
3424 wallet_name: &str,
3425 ) -> Result<Vec<Unspent>, AmpError> {
3426 tracing::debug!(
3427 "Collecting change data for asset {} from transaction {}",
3428 asset_id,
3429 txid
3430 );
3431
3432 // Use the raw listunspent RPC call to get full blinding information
3433 // This is essential for confidential transactions as the AMP API requires
3434 // both amountblinder and assetblinder fields
3435 let all_utxos = node_rpc
3436 .list_unspent_with_blinding_data(wallet_name)
3437 .await
3438 .map_err(|e| {
3439 e.with_context(
3440 "Failed to query unspent outputs with blinding data for change data collection",
3441 )
3442 })?;
3443
3444 // Filter UTXOs to only include those from the specified transaction
3445 let change_utxos: Vec<Unspent> = all_utxos
3446 .into_iter()
3447 .filter(|utxo| {
3448 // Match UTXOs that:
3449 // 1. Come from the specified transaction (txid matches)
3450 // 2. Are for the correct asset
3451 // 3. Are spendable
3452 utxo.txid == txid && utxo.asset == asset_id && utxo.spendable
3453 })
3454 .collect();
3455
3456 tracing::info!(
3457 "Collected {} change UTXOs for asset {} from transaction {}",
3458 change_utxos.len(),
3459 asset_id,
3460 txid
3461 );
3462
3463 // Log details of found change UTXOs for debugging
3464 for (index, utxo) in change_utxos.iter().enumerate() {
3465 tracing::debug!(
3466 "Change UTXO {}: txid={}, vout={}, amount={}, asset={}, amountblinder={:?}, assetblinder={:?}",
3467 index + 1,
3468 utxo.txid,
3469 utxo.vout,
3470 utxo.amount,
3471 utxo.asset,
3472 utxo.amountblinder,
3473 utxo.assetblinder
3474 );
3475 }
3476
3477 // Handle the case where no change outputs exist
3478 if change_utxos.is_empty() {
3479 tracing::info!(
3480 "No change outputs found for asset {} in transaction {} - this is normal if all funds were distributed",
3481 asset_id,
3482 txid
3483 );
3484 }
3485
3486 Ok(change_utxos)
3487 }
3488
3489 /// Lists unspent outputs with full blinding data for confidential transactions
3490 ///
3491 /// This method calls the raw `listunspent` RPC to get complete UTXO information
3492 /// including blinding data (amountblinder and assetblinder) which is required
3493 /// for confidential transaction confirmation with the AMP API.
3494 ///
3495 /// # Arguments
3496 /// * `wallet_name` - Name of the Elements wallet to query
3497 ///
3498 /// # Returns
3499 /// Returns a vector of `Unspent` structs with complete blinding information
3500 ///
3501 /// # Errors
3502 /// Returns an error if the RPC call fails
3503 ///
3504 /// # Examples
3505 /// ```no_run
3506 /// # use amp_rs::ElementsRpc;
3507 /// # #[tokio::main]
3508 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3509 /// let rpc = ElementsRpc::from_env()?;
3510 /// let utxos = rpc.list_unspent_with_blinding_data("wallet_name").await?;
3511 /// for utxo in utxos {
3512 /// println!("UTXO: {} with blinders: {:?}, {:?}",
3513 /// utxo.txid, utxo.amountblinder, utxo.assetblinder);
3514 /// }
3515 /// # Ok(())
3516 /// # }
3517 /// ```
3518 pub async fn list_unspent_with_blinding_data(
3519 &self,
3520 wallet_name: &str,
3521 ) -> Result<Vec<Unspent>, AmpError> {
3522 tracing::debug!(
3523 "Listing unspent outputs with blinding data for wallet: {}",
3524 wallet_name
3525 );
3526
3527 // First load the wallet to ensure it's available
3528 self.load_wallet(wallet_name).await?;
3529
3530 // Call listunspent with parameters to get all UTXOs
3531 // Parameters: minconf, maxconf, addresses, include_unsafe, query_options
3532 let params = serde_json::json!([
3533 0, // minconf: include unconfirmed
3534 9_999_999, // maxconf: include all confirmed
3535 [], // addresses: empty array means all addresses
3536 true, // include_unsafe: include unconfirmed transactions
3537 {} // query_options: empty object for default options
3538 ]);
3539
3540 // Use the wallet-specific RPC endpoint
3541 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3542
3543 let request = RpcRequest {
3544 jsonrpc: "1.0".to_string(),
3545 id: "amp-client".to_string(),
3546 method: "listunspent".to_string(),
3547 params,
3548 };
3549
3550 let response = self
3551 .client
3552 .post(&wallet_url)
3553 .basic_auth(&self.username, Some(&self.password))
3554 .json(&request)
3555 .send()
3556 .await
3557 .map_err(|e| AmpError::rpc(format!("Failed to send listunspent RPC request: {e}")))?;
3558
3559 if !response.status().is_success() {
3560 let status = response.status();
3561 let error_body = response
3562 .text()
3563 .await
3564 .unwrap_or_else(|_| "Unable to read error body".to_string());
3565 return Err(AmpError::rpc(format!(
3566 "Listunspent RPC request failed with status: {status} - Body: {error_body}"
3567 )));
3568 }
3569
3570 let rpc_response: RpcResponse<Vec<Unspent>> = response
3571 .json()
3572 .await
3573 .map_err(|e| AmpError::rpc(format!("Failed to parse listunspent RPC response: {e}")))?;
3574
3575 if let Some(error) = rpc_response.error {
3576 return Err(AmpError::rpc(format!(
3577 "Listunspent RPC error: {} (code: {})",
3578 error.message, error.code
3579 )));
3580 }
3581
3582 let utxos = rpc_response.result.unwrap_or_default();
3583 tracing::info!(
3584 "Retrieved {} UTXOs with blinding data from wallet {}",
3585 utxos.len(),
3586 wallet_name
3587 );
3588
3589 Ok(utxos)
3590 }
3591
3592 /// Creates a standard wallet in Elements (Elements-first approach)
3593 ///
3594 /// This method creates a new standard wallet in the Elements node that can generate
3595 /// addresses and private keys. This is part of the Elements-first approach where
3596 /// we create the wallet in Elements first, then export keys to LWK.
3597 ///
3598 /// # Arguments
3599 /// * `wallet_name` - Name for the new wallet
3600 ///
3601 /// # Errors
3602 /// Returns an error if the RPC call fails
3603 ///
3604 /// # Examples
3605 /// ```no_run
3606 /// # use amp_rs::ElementsRpc;
3607 /// # #[tokio::main]
3608 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3609 /// let rpc = ElementsRpc::from_env()?;
3610 /// rpc.create_elements_wallet("test_wallet").await?;
3611 /// # Ok(())
3612 /// # }
3613 /// ```
3614 pub async fn create_elements_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
3615 let params = serde_json::json!([wallet_name]);
3616
3617 let _result: serde_json::Value = self.rpc_call("createwallet", params).await?;
3618
3619 tracing::info!("Successfully created Elements wallet: {}", wallet_name);
3620 Ok(())
3621 }
3622
3623 /// Get a new address from an Elements wallet
3624 ///
3625 /// This method requests a new address from the specified Elements wallet.
3626 /// The address will be generated by Elements and can be used for receiving funds.
3627 /// Defaults to native segwit (bech32) addresses for optimal compatibility.
3628 ///
3629 /// # Arguments
3630 /// * `wallet_name` - Name of the wallet to get address from
3631 /// * `address_type` - Optional address type ("bech32", "legacy", "p2sh-segwit"). Defaults to "bech32"
3632 ///
3633 /// # Errors
3634 /// Returns an error if the RPC call fails or the response format is unexpected
3635 ///
3636 /// # Examples
3637 /// ```no_run
3638 /// # use amp_rs::ElementsRpc;
3639 /// # #[tokio::main]
3640 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3641 /// let rpc = ElementsRpc::from_env()?;
3642 ///
3643 /// // Generate native segwit address (default)
3644 /// let address = rpc.get_new_address("test_wallet", None).await?;
3645 ///
3646 /// // Or explicitly request native segwit
3647 /// let bech32_address = rpc.get_new_address("test_wallet", Some("bech32")).await?;
3648 ///
3649 /// println!("Native segwit address: {}", address);
3650 /// # Ok(())
3651 /// # }
3652 /// ```
3653 pub async fn get_new_address(
3654 &self,
3655 wallet_name: &str,
3656 address_type: Option<&str>,
3657 ) -> Result<String, AmpError> {
3658 // First load the wallet to ensure it's available
3659 self.load_wallet(wallet_name).await?;
3660
3661 // Set default to native segwit (bech32) for Elements
3662 let addr_type = address_type.unwrap_or("bech32");
3663
3664 // For Elements, we need to use the correct parameters for getnewaddress
3665 // getnewaddress [label] [address_type]
3666 let params = serde_json::json!(["", addr_type]);
3667
3668 // Create RPC request for getnewaddress
3669 let request = RpcRequest {
3670 jsonrpc: "1.0".to_string(),
3671 id: "amp-client".to_string(),
3672 method: "getnewaddress".to_string(),
3673 params,
3674 };
3675
3676 // Use the wallet-specific RPC endpoint
3677 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3678
3679 let response = self
3680 .client
3681 .post(&wallet_url)
3682 .basic_auth(&self.username, Some(&self.password))
3683 .json(&request)
3684 .send()
3685 .await
3686 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3687
3688 if !response.status().is_success() {
3689 let status = response.status();
3690 let error_body = response
3691 .text()
3692 .await
3693 .unwrap_or_else(|_| "Unable to read error body".to_string());
3694 return Err(AmpError::rpc(format!(
3695 "RPC request failed with status: {status} - Body: {error_body}"
3696 )));
3697 }
3698
3699 let rpc_response: RpcResponse<serde_json::Value> = response
3700 .json()
3701 .await
3702 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
3703
3704 if let Some(error) = rpc_response.error {
3705 return Err(AmpError::rpc(format!(
3706 "RPC error getting new address: {} (code: {})",
3707 error.message, error.code
3708 )));
3709 }
3710
3711 if let Some(result) = rpc_response.result {
3712 if let Some(address) = result.as_str() {
3713 tracing::info!("Generated new {} address: {}", addr_type, address);
3714 return Ok(address.to_string());
3715 }
3716 }
3717
3718 Err(AmpError::rpc(format!(
3719 "Failed to get new address from wallet '{wallet_name}': unexpected response format"
3720 )))
3721 }
3722
3723 /// Get the confidential version of an address from Elements wallet
3724 ///
3725 /// This method takes a regular (unconfidential) address and returns its confidential
3726 /// counterpart, which includes blinding keys for confidential transactions.
3727 ///
3728 /// # Arguments
3729 ///
3730 /// * `wallet_name` - Name of the Elements wallet
3731 /// * `address` - The unconfidential address to get info for
3732 ///
3733 /// # Returns
3734 ///
3735 /// Returns the confidential address string
3736 ///
3737 /// # Example
3738 ///
3739 /// ```no_run
3740 /// # use amp_rs::ElementsRpc;
3741 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
3742 /// let rpc = ElementsRpc::from_env()?;
3743 /// let unconfidential_address = "tex1q...";
3744 /// // Note: This would need to be called in an async context
3745 /// // let confidential_address = rpc.get_confidential_address("test_wallet", unconfidential_address).await?;
3746 /// // println!("Confidential address: {}", confidential_address);
3747 /// # Ok(())
3748 /// # }
3749 /// ```
3750 /// Gets the confidential address for a given unconfidential address from a wallet
3751 ///
3752 /// # Errors
3753 /// Returns an error if the RPC call fails or the response format is unexpected
3754 pub async fn get_confidential_address(
3755 &self,
3756 wallet_name: &str,
3757 address: &str,
3758 ) -> Result<String, AmpError> {
3759 // First load the wallet to ensure it's available
3760 self.load_wallet(wallet_name).await?;
3761
3762 let params = serde_json::json!([address]);
3763
3764 // Create RPC request for getaddressinfo
3765 let request = RpcRequest {
3766 jsonrpc: "1.0".to_string(),
3767 id: "amp-client".to_string(),
3768 method: "getaddressinfo".to_string(),
3769 params,
3770 };
3771
3772 // Use the wallet-specific RPC endpoint
3773 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3774
3775 let response = self
3776 .client
3777 .post(&wallet_url)
3778 .basic_auth(&self.username, Some(&self.password))
3779 .json(&request)
3780 .send()
3781 .await
3782 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3783
3784 if !response.status().is_success() {
3785 let status = response.status();
3786 let error_body = response
3787 .text()
3788 .await
3789 .unwrap_or_else(|_| "Unable to read error body".to_string());
3790 return Err(AmpError::rpc(format!(
3791 "RPC request failed with status: {status} - Body: {error_body}"
3792 )));
3793 }
3794
3795 let rpc_response: RpcResponse<serde_json::Value> = response
3796 .json()
3797 .await
3798 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
3799
3800 if let Some(error) = rpc_response.error {
3801 return Err(AmpError::rpc(format!(
3802 "RPC error getting address info: {} (code: {})",
3803 error.message, error.code
3804 )));
3805 }
3806
3807 if let Some(result) = rpc_response.result {
3808 if let Some(confidential_address) = result.get("confidential").and_then(|v| v.as_str())
3809 {
3810 tracing::info!("Retrieved confidential address for: {}", address);
3811 return Ok(confidential_address.to_string());
3812 }
3813 }
3814
3815 Err(AmpError::rpc(format!(
3816 "Failed to get confidential address for '{address}': unexpected response format"
3817 )))
3818 }
3819
3820 /// Get the private key for an address from Elements wallet
3821 ///
3822 /// This method exports the private key for a specific address from the Elements wallet.
3823 /// The private key can then be imported into LWK for signing.
3824 ///
3825 /// Note: This is a simplified implementation that returns a placeholder private key.
3826 /// For production use, implement proper wallet-specific RPC calls.
3827 ///
3828 /// # Arguments
3829 /// * `wallet_name` - Name of the wallet containing the address
3830 /// * `address` - The address to get the private key for
3831 ///
3832 /// # Errors
3833 /// Returns an error if the RPC call fails
3834 ///
3835 /// # Examples
3836 /// ```no_run
3837 /// # use amp_rs::ElementsRpc;
3838 /// # #[tokio::main]
3839 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3840 /// let rpc = ElementsRpc::from_env()?;
3841 /// let address = rpc.get_new_address("test_wallet", None).await?;
3842 /// let private_key = rpc.dump_private_key("test_wallet", &address).await?;
3843 /// println!("Private key: {}", private_key);
3844 /// # Ok(())
3845 /// # }
3846 /// ```
3847 pub async fn dump_private_key(
3848 &self,
3849 wallet_name: &str,
3850 address: &str,
3851 ) -> Result<String, AmpError> {
3852 // First load the wallet to ensure it's available
3853 self.load_wallet(wallet_name).await?;
3854
3855 let params = serde_json::json!([address]);
3856
3857 // Create RPC request for dumpprivkey
3858 let request = RpcRequest {
3859 jsonrpc: "1.0".to_string(),
3860 id: "amp-client".to_string(),
3861 method: "dumpprivkey".to_string(),
3862 params,
3863 };
3864
3865 // Use the wallet-specific RPC endpoint
3866 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3867
3868 let response = self
3869 .client
3870 .post(&wallet_url)
3871 .basic_auth(&self.username, Some(&self.password))
3872 .json(&request)
3873 .send()
3874 .await
3875 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3876
3877 if !response.status().is_success() {
3878 return Err(AmpError::rpc(format!(
3879 "RPC request failed with status: {}",
3880 response.status()
3881 )));
3882 }
3883
3884 let rpc_response: RpcResponse<serde_json::Value> = response
3885 .json()
3886 .await
3887 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
3888
3889 if let Some(error) = rpc_response.error {
3890 return Err(AmpError::rpc(format!(
3891 "RPC error dumping private key: {} (code: {})",
3892 error.message, error.code
3893 )));
3894 }
3895
3896 if let Some(result) = rpc_response.result {
3897 if let Some(private_key) = result.as_str() {
3898 tracing::info!("Successfully exported private key for address: {}", address);
3899 return Ok(private_key.to_string());
3900 }
3901 }
3902
3903 Err(AmpError::rpc(format!(
3904 "Failed to dump private key for address '{address}': unexpected response format"
3905 )))
3906 }
3907
3908 /// Creates a descriptor wallet in Elements
3909 ///
3910 /// # Arguments
3911 /// * `wallet_name` - Name for the new wallet
3912 ///
3913 /// # Errors
3914 /// Returns an error if the RPC call fails
3915 ///
3916 /// # Examples
3917 /// ```no_run
3918 /// # use amp_rs::ElementsRpc;
3919 /// # #[tokio::main]
3920 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3921 /// let rpc = ElementsRpc::from_env()?;
3922 /// rpc.create_descriptor_wallet("test_wallet").await?;
3923 /// # Ok(())
3924 /// # }
3925 /// ```
3926 pub async fn create_descriptor_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
3927 let params = serde_json::json!([wallet_name, true]); // true enables descriptors
3928
3929 let _result: serde_json::Value = self.rpc_call("createwallet", params).await?;
3930
3931 tracing::info!("Successfully created descriptor wallet: {}", wallet_name);
3932 Ok(())
3933 }
3934
3935 /// Imports a single descriptor into an Elements wallet
3936 ///
3937 /// This method imports a descriptor that enables the wallet to scan and recognize
3938 /// addresses/UTXOs from a mnemonic. For LWK descriptors with `<0;1>/*` format,
3939 /// a single descriptor covers both receive and change addresses.
3940 ///
3941 /// # Arguments
3942 /// * `wallet_name` - Name of the wallet to import descriptor into
3943 /// * `descriptor` - The descriptor to import
3944 ///
3945 /// # Errors
3946 /// Returns an error if the RPC call fails
3947 ///
3948 /// # Examples
3949 /// ```no_run
3950 /// # use amp_rs::ElementsRpc;
3951 /// # #[tokio::main]
3952 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3953 /// let rpc = ElementsRpc::from_env()?;
3954 /// let descriptor = "ct(slip77(...),elwpkh([...]/84h/1h/0h]tpub.../<0;1>/*))#checksum";
3955 /// rpc.import_descriptor("test_wallet", descriptor).await?;
3956 /// # Ok(())
3957 /// # }
3958 /// ```
3959 pub async fn import_descriptor(
3960 &self,
3961 wallet_name: &str,
3962 descriptor: &str,
3963 ) -> Result<(), AmpError> {
3964 tracing::info!("Importing descriptor into wallet: {}", wallet_name);
3965 tracing::debug!("Descriptor: {}", descriptor);
3966
3967 let descriptors = serde_json::json!([
3968 {
3969 "desc": descriptor,
3970 "timestamp": "now",
3971 "active": true,
3972 "internal": false // For LWK descriptors with <0;1>/*, this covers both chains
3973 }
3974 ]);
3975
3976 // Use -rpcwallet parameter to specify the wallet
3977 let request = RpcRequest {
3978 jsonrpc: "1.0".to_string(),
3979 id: "amp-client".to_string(),
3980 method: "importdescriptors".to_string(),
3981 params: descriptors,
3982 };
3983
3984 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3985
3986 let response = self
3987 .client
3988 .post(&wallet_url)
3989 .basic_auth(&self.username, Some(&self.password))
3990 .json(&request)
3991 .send()
3992 .await
3993 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3994
3995 if !response.status().is_success() {
3996 return Err(AmpError::rpc(format!(
3997 "RPC request failed with status: {}",
3998 response.status()
3999 )));
4000 }
4001
4002 let rpc_response: RpcResponse<serde_json::Value> = response
4003 .json()
4004 .await
4005 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4006
4007 if let Some(error) = rpc_response.error {
4008 return Err(AmpError::rpc(format!(
4009 "RPC error {}: {}",
4010 error.code, error.message
4011 )));
4012 }
4013
4014 let result = rpc_response
4015 .result
4016 .ok_or_else(|| AmpError::rpc("RPC response missing result field".to_string()))?;
4017
4018 // Check if descriptor was imported successfully
4019 if let Some(results) = result.as_array() {
4020 if let Some(result) = results.first() {
4021 if let Some(success) = result.get("success").and_then(serde_json::Value::as_bool) {
4022 if !success {
4023 let error_msg = result
4024 .get("error")
4025 .and_then(|e| e.get("message"))
4026 .and_then(|m| m.as_str())
4027 .unwrap_or("Unknown error");
4028 return Err(AmpError::rpc(format!(
4029 "Failed to import descriptor: {error_msg}"
4030 )));
4031 }
4032 } else {
4033 return Err(AmpError::rpc(format!(
4034 "Invalid response format for descriptor import: {result:?}"
4035 )));
4036 }
4037 }
4038 } else {
4039 return Err(AmpError::rpc(format!(
4040 "Invalid response format: expected array, got {result:?}"
4041 )));
4042 }
4043
4044 tracing::info!(
4045 "Successfully imported descriptor into wallet: {}",
4046 wallet_name
4047 );
4048 Ok(())
4049 }
4050
4051 /// Imports descriptors into an Elements wallet (legacy method for compatibility)
4052 ///
4053 /// This method imports descriptors that enable the wallet to scan and recognize
4054 /// addresses/UTXOs from a mnemonic. If both descriptors are the same (as with LWK
4055 /// descriptors using `<0;1>/*` format), only one descriptor is imported.
4056 ///
4057 /// # Arguments
4058 /// * `wallet_name` - Name of the wallet to import descriptors into
4059 /// * `receive_descriptor` - The receive descriptor
4060 /// * `change_descriptor` - The change descriptor
4061 ///
4062 /// # Errors
4063 /// Returns an error if the RPC call fails
4064 ///
4065 /// # Examples
4066 /// ```no_run
4067 /// # use amp_rs::ElementsRpc;
4068 /// # #[tokio::main]
4069 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4070 /// let rpc = ElementsRpc::from_env()?;
4071 /// let descriptor = "ct(slip77(...),elwpkh([...]/84h/1h/0h]tpub.../<0;1>/*))#checksum";
4072 /// rpc.import_descriptors("test_wallet", descriptor, descriptor).await?;
4073 /// # Ok(())
4074 /// # }
4075 /// ```
4076 #[allow(clippy::cognitive_complexity)]
4077 pub async fn import_descriptors(
4078 &self,
4079 wallet_name: &str,
4080 receive_descriptor: &str,
4081 change_descriptor: &str,
4082 ) -> Result<(), AmpError> {
4083 // If both descriptors are the same (LWK case), import only once
4084 if receive_descriptor == change_descriptor {
4085 return self
4086 .import_descriptor(wallet_name, receive_descriptor)
4087 .await;
4088 }
4089
4090 tracing::info!(
4091 "Importing separate receive and change descriptors into wallet: {}",
4092 wallet_name
4093 );
4094 tracing::debug!("Receive descriptor: {}", receive_descriptor);
4095 tracing::debug!("Change descriptor: {}", change_descriptor);
4096
4097 let descriptors = serde_json::json!([
4098 {
4099 "desc": receive_descriptor,
4100 "timestamp": "now",
4101 "active": true,
4102 "internal": false
4103 },
4104 {
4105 "desc": change_descriptor,
4106 "timestamp": "now",
4107 "active": true,
4108 "internal": true
4109 }
4110 ]);
4111
4112 // Use -rpcwallet parameter to specify the wallet
4113 let request = RpcRequest {
4114 jsonrpc: "1.0".to_string(),
4115 id: "amp-client".to_string(),
4116 method: "importdescriptors".to_string(),
4117 params: descriptors,
4118 };
4119
4120 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4121
4122 let response = self
4123 .client
4124 .post(&wallet_url)
4125 .basic_auth(&self.username, Some(&self.password))
4126 .json(&request)
4127 .send()
4128 .await
4129 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4130
4131 if !response.status().is_success() {
4132 return Err(AmpError::rpc(format!(
4133 "RPC request failed with status: {}",
4134 response.status()
4135 )));
4136 }
4137
4138 let rpc_response: RpcResponse<serde_json::Value> = response
4139 .json()
4140 .await
4141 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4142
4143 if let Some(error) = rpc_response.error {
4144 return Err(AmpError::rpc(format!(
4145 "RPC error {}: {}",
4146 error.code, error.message
4147 )));
4148 }
4149
4150 let result = rpc_response
4151 .result
4152 .ok_or_else(|| AmpError::rpc("RPC response missing result field".to_string()))?;
4153
4154 // Check if both descriptors were imported successfully
4155 if let Some(results) = result.as_array() {
4156 for (i, result) in results.iter().enumerate() {
4157 if let Some(success) = result.get("success").and_then(serde_json::Value::as_bool) {
4158 if !success {
4159 let desc_type = if i == 0 { "receive" } else { "change" };
4160 let error_msg = result
4161 .get("error")
4162 .and_then(|e| e.get("message"))
4163 .and_then(|m| m.as_str())
4164 .unwrap_or("Unknown error");
4165 return Err(AmpError::rpc(format!(
4166 "Failed to import {desc_type} descriptor: {error_msg}"
4167 )));
4168 }
4169 } else {
4170 return Err(AmpError::rpc(format!(
4171 "Invalid response format for descriptor import: {result:?}"
4172 )));
4173 }
4174 }
4175 } else {
4176 return Err(AmpError::rpc(format!(
4177 "Invalid response format: expected array, got {result:?}"
4178 )));
4179 }
4180
4181 tracing::info!(
4182 "Successfully imported descriptors into wallet: {}",
4183 wallet_name
4184 );
4185 Ok(())
4186 }
4187
4188 /// Sets up a wallet with descriptors from a mnemonic
4189 ///
4190 /// This is a convenience method that combines wallet creation and descriptor import.
4191 /// It creates a descriptor wallet and imports the receive and change descriptors
4192 /// generated from the provided mnemonic.
4193 ///
4194 /// # Arguments
4195 /// * `wallet_name` - Name for the new wallet
4196 /// * `receive_descriptor` - The receive descriptor (external chain /0/*)
4197 /// * `change_descriptor` - The change descriptor (internal chain /1/*)
4198 ///
4199 /// # Errors
4200 /// Returns an error if wallet creation or descriptor import fails
4201 ///
4202 /// # Examples
4203 /// ```no_run
4204 /// # use amp_rs::ElementsRpc;
4205 /// # #[tokio::main]
4206 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4207 /// let rpc = ElementsRpc::from_env()?;
4208 /// let receive_desc = "wpkh([d34db33f/84h/1h/0h]xprv.../0/*)#checksum";
4209 /// let change_desc = "wpkh([d34db33f/84h/1h/0h]xprv.../1/*)#checksum";
4210 /// rpc.setup_wallet_with_descriptors("test_wallet", receive_desc, change_desc).await?;
4211 /// # Ok(())
4212 /// # }
4213 /// ```
4214 #[allow(clippy::cognitive_complexity)]
4215 pub async fn setup_wallet_with_descriptors(
4216 &self,
4217 wallet_name: &str,
4218 receive_descriptor: &str,
4219 change_descriptor: &str,
4220 ) -> Result<(), AmpError> {
4221 tracing::info!("Setting up wallet with descriptors: {}", wallet_name);
4222
4223 // Try to create the wallet (may fail if it already exists)
4224 match self.create_descriptor_wallet(wallet_name).await {
4225 Ok(()) => {
4226 tracing::info!("Created new descriptor wallet: {}", wallet_name);
4227 }
4228 Err(e) => {
4229 let error_msg = e.to_string();
4230 if error_msg.contains("already exists")
4231 || error_msg.contains("Database already exists")
4232 {
4233 tracing::info!(
4234 "Wallet {} already exists, proceeding with descriptor import",
4235 wallet_name
4236 );
4237 } else {
4238 return Err(e);
4239 }
4240 }
4241 }
4242
4243 // Import the descriptors
4244 self.import_descriptors(wallet_name, receive_descriptor, change_descriptor)
4245 .await?;
4246
4247 tracing::info!(
4248 "Successfully set up wallet with descriptors: {}",
4249 wallet_name
4250 );
4251 Ok(())
4252 }
4253
4254 /// Exports a wallet to a file using dumpwallet RPC
4255 ///
4256 /// # Arguments
4257 /// * `wallet_name` - Name of the wallet to export
4258 /// * `file_path` - Path where the wallet dump file will be created
4259 ///
4260 /// # Errors
4261 /// Returns an error if the RPC call fails or the wallet cannot be exported
4262 ///
4263 /// # Examples
4264 /// ```no_run
4265 /// # use amp_rs::ElementsRpc;
4266 /// # #[tokio::main]
4267 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4268 /// let rpc = ElementsRpc::from_env()?;
4269 /// rpc.dump_wallet("my_wallet", "/tmp/wallet_export.dat").await?;
4270 /// # Ok(())
4271 /// # }
4272 /// ```
4273 pub async fn dump_wallet(&self, wallet_name: &str, file_path: &str) -> Result<(), AmpError> {
4274 // First load the wallet to ensure it's available
4275 self.load_wallet(wallet_name).await?;
4276
4277 let params = serde_json::json!([file_path]);
4278
4279 // Create RPC request for dumpwallet
4280 let request = RpcRequest {
4281 jsonrpc: "1.0".to_string(),
4282 id: "amp-client".to_string(),
4283 method: "dumpwallet".to_string(),
4284 params,
4285 };
4286
4287 // Use the wallet-specific RPC endpoint
4288 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4289
4290 let response = self
4291 .client
4292 .post(&wallet_url)
4293 .basic_auth(&self.username, Some(&self.password))
4294 .json(&request)
4295 .send()
4296 .await
4297 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4298
4299 if !response.status().is_success() {
4300 return Err(AmpError::rpc(format!(
4301 "RPC request failed with status: {}",
4302 response.status()
4303 )));
4304 }
4305
4306 let rpc_response: RpcResponse<serde_json::Value> = response
4307 .json()
4308 .await
4309 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4310
4311 if let Some(error) = rpc_response.error {
4312 return Err(AmpError::rpc(format!(
4313 "RPC error dumping wallet: {} (code: {})",
4314 error.message, error.code
4315 )));
4316 }
4317
4318 tracing::info!(
4319 "Successfully exported wallet {} to {}",
4320 wallet_name,
4321 file_path
4322 );
4323 Ok(())
4324 }
4325
4326 /// Imports a wallet from a file using importwallet RPC
4327 ///
4328 /// # Arguments
4329 /// * `wallet_name` - Name of the wallet to import into
4330 /// * `file_path` - Path to the wallet dump file to import
4331 ///
4332 /// # Errors
4333 /// Returns an error if the RPC call fails or the wallet cannot be imported
4334 ///
4335 /// # Examples
4336 /// ```no_run
4337 /// # use amp_rs::ElementsRpc;
4338 /// # #[tokio::main]
4339 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4340 /// let rpc = ElementsRpc::from_env()?;
4341 /// rpc.import_wallet("my_wallet", "/tmp/wallet_export.dat").await?;
4342 /// # Ok(())
4343 /// # }
4344 /// ```
4345 pub async fn import_wallet(&self, wallet_name: &str, file_path: &str) -> Result<(), AmpError> {
4346 // First load the wallet to ensure it's available
4347 self.load_wallet(wallet_name).await?;
4348
4349 let params = serde_json::json!([file_path]);
4350
4351 // Create RPC request for importwallet
4352 let request = RpcRequest {
4353 jsonrpc: "1.0".to_string(),
4354 id: "amp-client".to_string(),
4355 method: "importwallet".to_string(),
4356 params,
4357 };
4358
4359 // Use the wallet-specific RPC endpoint
4360 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4361
4362 let response = self
4363 .client
4364 .post(&wallet_url)
4365 .basic_auth(&self.username, Some(&self.password))
4366 .json(&request)
4367 .send()
4368 .await
4369 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4370
4371 if !response.status().is_success() {
4372 return Err(AmpError::rpc(format!(
4373 "RPC request failed with status: {}",
4374 response.status()
4375 )));
4376 }
4377
4378 let rpc_response: RpcResponse<serde_json::Value> = response
4379 .json()
4380 .await
4381 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4382
4383 if let Some(error) = rpc_response.error {
4384 return Err(AmpError::rpc(format!(
4385 "RPC error importing wallet: {} (code: {})",
4386 error.message, error.code
4387 )));
4388 }
4389
4390 tracing::info!(
4391 "Successfully imported wallet {} from {}",
4392 wallet_name,
4393 file_path
4394 );
4395 Ok(())
4396 }
4397
4398 /// Exports a blinding key for a confidential address using dumpblindingkey RPC
4399 ///
4400 /// # Arguments
4401 /// * `wallet_name` - Name of the wallet containing the address
4402 /// * `address` - The confidential address to export the blinding key for
4403 ///
4404 /// # Errors
4405 /// Returns an error if the RPC call fails or the address doesn't have a blinding key
4406 ///
4407 /// # Examples
4408 /// ```no_run
4409 /// # use amp_rs::ElementsRpc;
4410 /// # #[tokio::main]
4411 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4412 /// let rpc = ElementsRpc::from_env()?;
4413 /// let key = rpc.dump_blinding_key("my_wallet", "VTpz...").await?;
4414 /// println!("Blinding key: {}", key);
4415 /// # Ok(())
4416 /// # }
4417 /// ```
4418 pub async fn dump_blinding_key(
4419 &self,
4420 wallet_name: &str,
4421 address: &str,
4422 ) -> Result<String, AmpError> {
4423 // First load the wallet to ensure it's available
4424 self.load_wallet(wallet_name).await?;
4425
4426 let params = serde_json::json!([address]);
4427
4428 // Create RPC request for dumpblindingkey
4429 let request = RpcRequest {
4430 jsonrpc: "1.0".to_string(),
4431 id: "amp-client".to_string(),
4432 method: "dumpblindingkey".to_string(),
4433 params,
4434 };
4435
4436 // Use the wallet-specific RPC endpoint
4437 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4438
4439 let response = self
4440 .client
4441 .post(&wallet_url)
4442 .basic_auth(&self.username, Some(&self.password))
4443 .json(&request)
4444 .send()
4445 .await
4446 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4447
4448 if !response.status().is_success() {
4449 return Err(AmpError::rpc(format!(
4450 "RPC request failed with status: {}",
4451 response.status()
4452 )));
4453 }
4454
4455 let rpc_response: RpcResponse<serde_json::Value> = response
4456 .json()
4457 .await
4458 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4459
4460 if let Some(error) = rpc_response.error {
4461 return Err(AmpError::rpc(format!(
4462 "RPC error dumping blinding key: {} (code: {})",
4463 error.message, error.code
4464 )));
4465 }
4466
4467 if let Some(result) = rpc_response.result {
4468 if let Some(blinding_key) = result.as_str() {
4469 tracing::info!(
4470 "Successfully exported blinding key for address: {}",
4471 address
4472 );
4473 return Ok(blinding_key.to_string());
4474 }
4475 }
4476
4477 Err(AmpError::rpc(format!(
4478 "Failed to dump blinding key for address '{address}': unexpected response format"
4479 )))
4480 }
4481
4482 /// Imports a blinding key for a confidential address using importblindingkey RPC
4483 ///
4484 /// # Arguments
4485 /// * `wallet_name` - Name of the wallet to import the blinding key into
4486 /// * `address` - The confidential address to import the blinding key for
4487 /// * `blinding_key` - The blinding key to import
4488 ///
4489 /// # Errors
4490 /// Returns an error if the RPC call fails or the blinding key cannot be imported
4491 ///
4492 /// # Examples
4493 /// ```no_run
4494 /// # use amp_rs::ElementsRpc;
4495 /// # #[tokio::main]
4496 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4497 /// let rpc = ElementsRpc::from_env()?;
4498 /// rpc.import_blinding_key("my_wallet", "VTpz...", "blinding_key_hex").await?;
4499 /// # Ok(())
4500 /// # }
4501 /// ```
4502 pub async fn import_blinding_key(
4503 &self,
4504 wallet_name: &str,
4505 address: &str,
4506 blinding_key: &str,
4507 ) -> Result<(), AmpError> {
4508 // First load the wallet to ensure it's available
4509 self.load_wallet(wallet_name).await?;
4510
4511 let params = serde_json::json!([address, blinding_key]);
4512
4513 // Create RPC request for importblindingkey
4514 let request = RpcRequest {
4515 jsonrpc: "1.0".to_string(),
4516 id: "amp-client".to_string(),
4517 method: "importblindingkey".to_string(),
4518 params,
4519 };
4520
4521 // Use the wallet-specific RPC endpoint
4522 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4523
4524 let response = self
4525 .client
4526 .post(&wallet_url)
4527 .basic_auth(&self.username, Some(&self.password))
4528 .json(&request)
4529 .send()
4530 .await
4531 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4532
4533 if !response.status().is_success() {
4534 return Err(AmpError::rpc(format!(
4535 "RPC request failed with status: {}",
4536 response.status()
4537 )));
4538 }
4539
4540 let rpc_response: RpcResponse<serde_json::Value> = response
4541 .json()
4542 .await
4543 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4544
4545 if let Some(error) = rpc_response.error {
4546 return Err(AmpError::rpc(format!(
4547 "RPC error importing blinding key: {} (code: {})",
4548 error.message, error.code
4549 )));
4550 }
4551
4552 tracing::info!(
4553 "Successfully imported blinding key for address: {}",
4554 address
4555 );
4556 Ok(())
4557 }
4558
4559 /// Gets wallet information using getwalletinfo RPC
4560 ///
4561 /// # Arguments
4562 /// * `wallet_name` - Name of the wallet to get information for
4563 ///
4564 /// # Errors
4565 /// Returns an error if the RPC call fails
4566 ///
4567 /// # Examples
4568 /// ```no_run
4569 /// # use amp_rs::ElementsRpc;
4570 /// # #[tokio::main]
4571 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4572 /// let rpc = ElementsRpc::from_env()?;
4573 /// let info = rpc.get_wallet_info("my_wallet").await?;
4574 /// println!("Wallet info: {:?}", info);
4575 /// # Ok(())
4576 /// # }
4577 /// ```
4578 pub async fn get_wallet_info(&self, wallet_name: &str) -> Result<serde_json::Value, AmpError> {
4579 // First load the wallet to ensure it's available
4580 self.load_wallet(wallet_name).await?;
4581
4582 let params = serde_json::json!([]);
4583
4584 // Create RPC request for getwalletinfo
4585 let request = RpcRequest {
4586 jsonrpc: "1.0".to_string(),
4587 id: "amp-client".to_string(),
4588 method: "getwalletinfo".to_string(),
4589 params,
4590 };
4591
4592 // Use the wallet-specific RPC endpoint
4593 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4594
4595 let response = self
4596 .client
4597 .post(&wallet_url)
4598 .basic_auth(&self.username, Some(&self.password))
4599 .json(&request)
4600 .send()
4601 .await
4602 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4603
4604 if !response.status().is_success() {
4605 return Err(AmpError::rpc(format!(
4606 "RPC request failed with status: {}",
4607 response.status()
4608 )));
4609 }
4610
4611 let rpc_response: RpcResponse<serde_json::Value> = response
4612 .json()
4613 .await
4614 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4615
4616 if let Some(error) = rpc_response.error {
4617 return Err(AmpError::rpc(format!(
4618 "RPC error getting wallet info: {} (code: {})",
4619 error.message, error.code
4620 )));
4621 }
4622
4623 if let Some(result) = rpc_response.result {
4624 tracing::info!("Successfully retrieved wallet info for: {}", wallet_name);
4625 return Ok(result);
4626 }
4627
4628 Err(AmpError::rpc(format!(
4629 "Failed to get wallet info for '{wallet_name}': unexpected response format"
4630 )))
4631 }
4632
4633 /// Gets the unconfidential address for a confidential address
4634 ///
4635 /// # Arguments
4636 /// * `wallet_name` - Name of the wallet
4637 /// * `confidential_address` - The confidential address to convert
4638 ///
4639 /// # Errors
4640 /// Returns an error if the RPC call fails
4641 ///
4642 /// # Examples
4643 /// ```no_run
4644 /// # use amp_rs::ElementsRpc;
4645 /// # #[tokio::main]
4646 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4647 /// let rpc = ElementsRpc::from_env()?;
4648 /// let unconf = rpc.get_unconfidential_address("my_wallet", "VTpz...").await?;
4649 /// println!("Unconfidential address: {}", unconf);
4650 /// # Ok(())
4651 /// # }
4652 /// ```
4653 pub async fn get_unconfidential_address(
4654 &self,
4655 wallet_name: &str,
4656 confidential_address: &str,
4657 ) -> Result<String, AmpError> {
4658 // First load the wallet to ensure it's available
4659 self.load_wallet(wallet_name).await?;
4660
4661 let params = serde_json::json!([confidential_address]);
4662
4663 // Create RPC request for getunconfidentialaddress
4664 let request = RpcRequest {
4665 jsonrpc: "1.0".to_string(),
4666 id: "amp-client".to_string(),
4667 method: "getunconfidentialaddress".to_string(),
4668 params,
4669 };
4670
4671 // Use the wallet-specific RPC endpoint
4672 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4673
4674 let response = self
4675 .client
4676 .post(&wallet_url)
4677 .basic_auth(&self.username, Some(&self.password))
4678 .json(&request)
4679 .send()
4680 .await
4681 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4682
4683 if !response.status().is_success() {
4684 return Err(AmpError::rpc(format!(
4685 "RPC request failed with status: {}",
4686 response.status()
4687 )));
4688 }
4689
4690 let rpc_response: RpcResponse<serde_json::Value> = response
4691 .json()
4692 .await
4693 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4694
4695 if let Some(error) = rpc_response.error {
4696 return Err(AmpError::rpc(format!(
4697 "RPC error getting unconfidential address: {} (code: {})",
4698 error.message, error.code
4699 )));
4700 }
4701
4702 if let Some(result) = rpc_response.result {
4703 if let Some(address) = result.as_str() {
4704 tracing::info!(
4705 "Successfully got unconfidential address for: {}",
4706 confidential_address
4707 );
4708 return Ok(address.to_string());
4709 }
4710 }
4711
4712 Err(AmpError::rpc(format!(
4713 "Failed to get unconfidential address for '{confidential_address}': unexpected response format"
4714 )))
4715 }
4716
4717 /// Imports a private key into the wallet using importprivkey RPC
4718 ///
4719 /// # Arguments
4720 /// * `wallet_name` - Name of the wallet to import into
4721 /// * `private_key` - The private key in WIF format
4722 /// * `label` - Optional label for the address
4723 /// * `rescan` - Whether to rescan the blockchain for transactions
4724 ///
4725 /// # Errors
4726 /// Returns an error if the RPC call fails
4727 ///
4728 /// # Examples
4729 /// ```no_run
4730 /// # use amp_rs::ElementsRpc;
4731 /// # #[tokio::main]
4732 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4733 /// let rpc = ElementsRpc::from_env()?;
4734 /// rpc.import_private_key("my_wallet", "cT1...", Some("my_address"), Some(false)).await?;
4735 /// # Ok(())
4736 /// # }
4737 /// ```
4738 pub async fn import_private_key(
4739 &self,
4740 wallet_name: &str,
4741 private_key: &str,
4742 label: Option<&str>,
4743 rescan: Option<bool>,
4744 ) -> Result<(), AmpError> {
4745 // First load the wallet to ensure it's available
4746 self.load_wallet(wallet_name).await?;
4747
4748 let params = serde_json::json!([private_key, label.unwrap_or(""), rescan.unwrap_or(false)]);
4749
4750 // Create RPC request for importprivkey
4751 let request = RpcRequest {
4752 jsonrpc: "1.0".to_string(),
4753 id: "amp-client".to_string(),
4754 method: "importprivkey".to_string(),
4755 params,
4756 };
4757
4758 // Use the wallet-specific RPC endpoint
4759 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4760
4761 let response = self
4762 .client
4763 .post(&wallet_url)
4764 .basic_auth(&self.username, Some(&self.password))
4765 .json(&request)
4766 .send()
4767 .await
4768 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4769
4770 if !response.status().is_success() {
4771 return Err(AmpError::rpc(format!(
4772 "RPC request failed with status: {}",
4773 response.status()
4774 )));
4775 }
4776
4777 let rpc_response: RpcResponse<serde_json::Value> = response
4778 .json()
4779 .await
4780 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4781
4782 if let Some(error) = rpc_response.error {
4783 return Err(AmpError::rpc(format!(
4784 "RPC error importing private key: {} (code: {})",
4785 error.message, error.code
4786 )));
4787 }
4788
4789 tracing::info!("Successfully imported private key");
4790 Ok(())
4791 }
4792
4793 /// Lists all descriptors in a wallet using listdescriptors RPC
4794 ///
4795 /// # Arguments
4796 /// * `wallet_name` - Name of the wallet
4797 /// * `private_keys` - Whether to include private keys in the output
4798 ///
4799 /// # Errors
4800 /// Returns an error if the RPC call fails
4801 ///
4802 /// # Examples
4803 /// ```no_run
4804 /// # use amp_rs::ElementsRpc;
4805 /// # #[tokio::main]
4806 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4807 /// let rpc = ElementsRpc::from_env()?;
4808 /// let descriptors = rpc.list_descriptors("my_wallet", Some(true)).await?;
4809 /// for desc in descriptors {
4810 /// println!("Descriptor: {}", desc);
4811 /// }
4812 /// # Ok(())
4813 /// # }
4814 /// ```
4815 pub async fn list_descriptors(
4816 &self,
4817 wallet_name: &str,
4818 private_keys: Option<bool>,
4819 ) -> Result<Vec<String>, AmpError> {
4820 // First load the wallet to ensure it's available
4821 self.load_wallet(wallet_name).await?;
4822
4823 let params = serde_json::json!([private_keys.unwrap_or(false)]);
4824
4825 // Create RPC request for listdescriptors
4826 let request = RpcRequest {
4827 jsonrpc: "1.0".to_string(),
4828 id: "amp-client".to_string(),
4829 method: "listdescriptors".to_string(),
4830 params,
4831 };
4832
4833 // Use the wallet-specific RPC endpoint
4834 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4835
4836 let response = self
4837 .client
4838 .post(&wallet_url)
4839 .basic_auth(&self.username, Some(&self.password))
4840 .json(&request)
4841 .send()
4842 .await
4843 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4844
4845 if !response.status().is_success() {
4846 return Err(AmpError::rpc(format!(
4847 "RPC request failed with status: {}",
4848 response.status()
4849 )));
4850 }
4851
4852 let rpc_response: RpcResponse<serde_json::Value> = response
4853 .json()
4854 .await
4855 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4856
4857 if let Some(error) = rpc_response.error {
4858 return Err(AmpError::rpc(format!(
4859 "RPC error listing descriptors: {} (code: {})",
4860 error.message, error.code
4861 )));
4862 }
4863
4864 if let Some(result) = rpc_response.result {
4865 // Result has a "descriptors" array with objects containing "desc" field
4866 if let Some(descriptors_array) = result.get("descriptors").and_then(|v| v.as_array()) {
4867 let descriptors: Vec<String> = descriptors_array
4868 .iter()
4869 .filter_map(|d| d.get("desc").and_then(|v| v.as_str()).map(String::from))
4870 .collect();
4871 tracing::info!(
4872 "Successfully retrieved {} descriptors for wallet: {}",
4873 descriptors.len(),
4874 wallet_name
4875 );
4876 return Ok(descriptors);
4877 }
4878 }
4879
4880 Ok(Vec::new())
4881 }
4882
4883 /// Gets all addresses in a wallet by label using getaddressesbylabel RPC
4884 ///
4885 /// # Arguments
4886 /// * `wallet_name` - Name of the wallet
4887 /// * `label` - Label to filter by (empty string for all addresses)
4888 ///
4889 /// # Errors
4890 /// Returns an error if the RPC call fails
4891 ///
4892 /// # Examples
4893 /// ```no_run
4894 /// # use amp_rs::ElementsRpc;
4895 /// # #[tokio::main]
4896 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4897 /// let rpc = ElementsRpc::from_env()?;
4898 /// let addresses = rpc.get_addresses_by_label("my_wallet", "").await?;
4899 /// for addr in addresses {
4900 /// println!("Address: {}", addr);
4901 /// }
4902 /// # Ok(())
4903 /// # }
4904 /// ```
4905 pub async fn get_addresses_by_label(
4906 &self,
4907 wallet_name: &str,
4908 label: &str,
4909 ) -> Result<Vec<String>, AmpError> {
4910 // First load the wallet to ensure it's available
4911 self.load_wallet(wallet_name).await?;
4912
4913 let params = serde_json::json!([label]);
4914
4915 // Create RPC request for getaddressesbylabel
4916 let request = RpcRequest {
4917 jsonrpc: "1.0".to_string(),
4918 id: "amp-client".to_string(),
4919 method: "getaddressesbylabel".to_string(),
4920 params,
4921 };
4922
4923 // Use the wallet-specific RPC endpoint
4924 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4925
4926 let response = self
4927 .client
4928 .post(&wallet_url)
4929 .basic_auth(&self.username, Some(&self.password))
4930 .json(&request)
4931 .send()
4932 .await
4933 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4934
4935 if !response.status().is_success() {
4936 return Err(AmpError::rpc(format!(
4937 "RPC request failed with status: {}",
4938 response.status()
4939 )));
4940 }
4941
4942 let rpc_response: RpcResponse<serde_json::Value> = response
4943 .json()
4944 .await
4945 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4946
4947 if let Some(error) = rpc_response.error {
4948 return Err(AmpError::rpc(format!(
4949 "RPC error getting addresses by label: {} (code: {})",
4950 error.message, error.code
4951 )));
4952 }
4953
4954 if let Some(result) = rpc_response.result {
4955 // Result is an object with addresses as keys
4956 if let Some(obj) = result.as_object() {
4957 let addresses: Vec<String> = obj.keys().cloned().collect();
4958 tracing::info!(
4959 "Successfully retrieved {} addresses for wallet: {}",
4960 addresses.len(),
4961 wallet_name
4962 );
4963 return Ok(addresses);
4964 }
4965 }
4966
4967 Ok(Vec::new())
4968 }
4969
4970 /// Lists addresses that have received transactions using listreceivedbyaddress RPC
4971 ///
4972 /// # Arguments
4973 /// * `wallet_name` - Name of the wallet to list addresses for
4974 /// * `min_conf` - Minimum number of confirmations (0 for unconfirmed)
4975 /// * `include_empty` - Whether to include addresses that haven't received payments
4976 ///
4977 /// # Errors
4978 /// Returns an error if the RPC call fails
4979 ///
4980 /// # Examples
4981 /// ```no_run
4982 /// # use amp_rs::ElementsRpc;
4983 /// # #[tokio::main]
4984 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4985 /// let rpc = ElementsRpc::from_env()?;
4986 /// let addresses = rpc.list_received_by_address("my_wallet", 0, true).await?;
4987 /// for addr in addresses {
4988 /// println!("Address: {:?}", addr);
4989 /// }
4990 /// # Ok(())
4991 /// # }
4992 /// ```
4993 pub async fn list_received_by_address(
4994 &self,
4995 wallet_name: &str,
4996 min_conf: u32,
4997 include_empty: bool,
4998 ) -> Result<Vec<ReceivedByAddress>, AmpError> {
4999 // First load the wallet to ensure it's available
5000 self.load_wallet(wallet_name).await?;
5001
5002 let params = serde_json::json!([min_conf, include_empty]);
5003
5004 // Create RPC request for listreceivedbyaddress
5005 let request = RpcRequest {
5006 jsonrpc: "1.0".to_string(),
5007 id: "amp-client".to_string(),
5008 method: "listreceivedbyaddress".to_string(),
5009 params,
5010 };
5011
5012 // Use the wallet-specific RPC endpoint
5013 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
5014
5015 let response = self
5016 .client
5017 .post(&wallet_url)
5018 .basic_auth(&self.username, Some(&self.password))
5019 .json(&request)
5020 .send()
5021 .await
5022 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
5023
5024 if !response.status().is_success() {
5025 return Err(AmpError::rpc(format!(
5026 "RPC request failed with status: {}",
5027 response.status()
5028 )));
5029 }
5030
5031 let rpc_response: RpcResponse<Vec<ReceivedByAddress>> = response
5032 .json()
5033 .await
5034 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
5035
5036 if let Some(error) = rpc_response.error {
5037 return Err(AmpError::rpc(format!(
5038 "RPC error listing received by address: {} (code: {})",
5039 error.message, error.code
5040 )));
5041 }
5042
5043 if let Some(result) = rpc_response.result {
5044 tracing::info!(
5045 "Successfully listed {} addresses for wallet: {}",
5046 result.len(),
5047 wallet_name
5048 );
5049 return Ok(result);
5050 }
5051
5052 Ok(Vec::new())
5053 }
5054}
5055
5056#[cfg(test)]
5057mod elements_rpc_tests {
5058 use super::*;
5059 use httpmock::prelude::*;
5060 use serial_test::serial;
5061 use std::collections::HashMap;
5062
5063 #[test]
5064 fn test_elements_rpc_new() {
5065 let rpc = ElementsRpc::new(
5066 "http://localhost:18884".to_string(),
5067 "user".to_string(),
5068 "pass".to_string(),
5069 );
5070
5071 assert_eq!(rpc.base_url, "http://localhost:18884");
5072 assert_eq!(rpc.username, "user");
5073 assert_eq!(rpc.password, "pass");
5074 }
5075
5076 #[test]
5077 #[serial]
5078 fn test_elements_rpc_from_env_missing_vars() {
5079 // Store original values to restore later
5080 let original_url = env::var("ELEMENTS_RPC_URL").ok();
5081 let original_user = env::var("ELEMENTS_RPC_USER").ok();
5082 let original_password = env::var("ELEMENTS_RPC_PASSWORD").ok();
5083
5084 // Clear environment variables to test error handling
5085 env::remove_var("ELEMENTS_RPC_URL");
5086 env::remove_var("ELEMENTS_RPC_USER");
5087 env::remove_var("ELEMENTS_RPC_PASSWORD");
5088
5089 let result = ElementsRpc::from_env();
5090 assert!(
5091 result.is_err(),
5092 "ElementsRpc::from_env() should fail when env vars are missing"
5093 );
5094
5095 match result.unwrap_err() {
5096 AmpError::Validation(msg) => {
5097 assert!(
5098 msg.contains("ELEMENTS_RPC_URL"),
5099 "Error message should mention missing ELEMENTS_RPC_URL"
5100 );
5101 }
5102 _ => panic!("Expected validation error"),
5103 }
5104
5105 // Restore original values or keep removed if they weren't set
5106 if let Some(val) = original_url {
5107 env::set_var("ELEMENTS_RPC_URL", val);
5108 }
5109 if let Some(val) = original_user {
5110 env::set_var("ELEMENTS_RPC_USER", val);
5111 }
5112 if let Some(val) = original_password {
5113 env::set_var("ELEMENTS_RPC_PASSWORD", val);
5114 }
5115 }
5116
5117 #[test]
5118 #[serial]
5119 fn test_elements_rpc_from_env_success() {
5120 // Store original values to restore later
5121 let original_url = env::var("ELEMENTS_RPC_URL").ok();
5122 let original_user = env::var("ELEMENTS_RPC_USER").ok();
5123 let original_password = env::var("ELEMENTS_RPC_PASSWORD").ok();
5124
5125 // Set test values
5126 env::set_var("ELEMENTS_RPC_URL", "http://localhost:18884");
5127 env::set_var("ELEMENTS_RPC_USER", "testuser");
5128 env::set_var("ELEMENTS_RPC_PASSWORD", "testpass");
5129
5130 let result = ElementsRpc::from_env();
5131 assert!(
5132 result.is_ok(),
5133 "ElementsRpc::from_env() should succeed when all env vars are set"
5134 );
5135
5136 let rpc = result.unwrap();
5137 assert_eq!(rpc.base_url, "http://localhost:18884");
5138 assert_eq!(rpc.username, "testuser");
5139 assert_eq!(rpc.password, "testpass");
5140
5141 // Restore original values or remove if they weren't set
5142 match original_url {
5143 Some(val) => env::set_var("ELEMENTS_RPC_URL", val),
5144 None => env::remove_var("ELEMENTS_RPC_URL"),
5145 }
5146 match original_user {
5147 Some(val) => env::set_var("ELEMENTS_RPC_USER", val),
5148 None => env::remove_var("ELEMENTS_RPC_USER"),
5149 }
5150 match original_password {
5151 Some(val) => env::set_var("ELEMENTS_RPC_PASSWORD", val),
5152 None => env::remove_var("ELEMENTS_RPC_PASSWORD"),
5153 }
5154 }
5155
5156 #[test]
5157 fn test_elements_rpc_method_signatures() {
5158 // Test that all new methods have correct signatures and can be called
5159 let rpc = ElementsRpc::new(
5160 "http://localhost:18884".to_string(),
5161 "user".to_string(),
5162 "pass".to_string(),
5163 );
5164
5165 // Test that methods exist and have correct signatures (compilation test)
5166 let _: std::pin::Pin<
5167 Box<dyn std::future::Future<Output = Result<Vec<Unspent>, AmpError>> + Send + '_>,
5168 > = Box::pin(rpc.list_unspent(Some("test_asset")));
5169
5170 let inputs = vec![TxInput {
5171 txid: "test_txid".to_string(),
5172 vout: 0,
5173 sequence: None,
5174 }];
5175 let outputs = std::collections::HashMap::new();
5176 let assets = std::collections::HashMap::new();
5177
5178 let _: std::pin::Pin<
5179 Box<dyn std::future::Future<Output = Result<String, AmpError>> + Send + '_>,
5180 > = Box::pin(rpc.create_raw_transaction(inputs, outputs, assets));
5181
5182 let _: std::pin::Pin<
5183 Box<dyn std::future::Future<Output = Result<String, AmpError>> + Send + '_>,
5184 > = Box::pin(rpc.send_raw_transaction("test_hex"));
5185
5186 let _: std::pin::Pin<
5187 Box<dyn std::future::Future<Output = Result<TransactionDetail, AmpError>> + Send + '_>,
5188 > = Box::pin(rpc.get_transaction("test_txid"));
5189 }
5190
5191 // Mock RPC response tests for UTXO and transaction operations
5192
5193 #[tokio::test]
5194 async fn test_get_network_info_success() {
5195 let server = MockServer::start();
5196
5197 let mock_response = serde_json::json!({
5198 "jsonrpc": "1.0",
5199 "id": "amp-client",
5200 "result": {
5201 "version": 220000,
5202 "subversion": "/Liquid:22.0.0/",
5203 "protocolversion": 70016,
5204 "localservices": "0000000000000409",
5205 "localrelay": true,
5206 "timeoffset": 0,
5207 "networkactive": true,
5208 "connections": 8,
5209 "networks": [],
5210 "relayfee": 0.00001000,
5211 "incrementalfee": 0.00001000,
5212 "localaddresses": [],
5213 "warnings": ""
5214 }
5215 });
5216
5217 let mock = server.mock(|when, then| {
5218 when.method(POST)
5219 .path("/")
5220 .header("authorization", "Basic dXNlcjpwYXNz") // base64 of "user:pass"
5221 .json_body(serde_json::json!({
5222 "jsonrpc": "1.0",
5223 "id": "amp-client",
5224 "method": "getnetworkinfo",
5225 "params": []
5226 }));
5227 then.status(200)
5228 .header("content-type", "application/json")
5229 .json_body(mock_response);
5230 });
5231
5232 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5233 let result = rpc.get_network_info().await;
5234
5235 assert!(result.is_ok());
5236 let network_info = result.unwrap();
5237 assert_eq!(network_info.version, 220000);
5238 assert_eq!(network_info.subversion, "/Liquid:22.0.0/");
5239 assert_eq!(network_info.connections, 8);
5240
5241 mock.assert();
5242 }
5243
5244 #[tokio::test]
5245 async fn test_get_blockchain_info_success() {
5246 let server = MockServer::start();
5247
5248 let mock_response = serde_json::json!({
5249 "jsonrpc": "1.0",
5250 "id": "amp-client",
5251 "result": {
5252 "chain": "liquidregtest",
5253 "blocks": 12345,
5254 "headers": 12345,
5255 "bestblockhash": "abc123def456789",
5256 "difficulty": 4.656542373906925e-10,
5257 "mediantime": 1640995200,
5258 "verificationprogress": 1.0,
5259 "initialblockdownload": false,
5260 "chainwork": "0000000000000000000000000000000000000000000000000000000000003039",
5261 "size_on_disk": 1234567,
5262 "pruned": false,
5263 "softforks": {},
5264 "warnings": ""
5265 }
5266 });
5267
5268 let mock = server.mock(|when, then| {
5269 when.method(POST)
5270 .path("/")
5271 .header("authorization", "Basic dXNlcjpwYXNz")
5272 .json_body(serde_json::json!({
5273 "jsonrpc": "1.0",
5274 "id": "amp-client",
5275 "method": "getblockchaininfo",
5276 "params": []
5277 }));
5278 then.status(200)
5279 .header("content-type", "application/json")
5280 .json_body(mock_response);
5281 });
5282
5283 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5284 let result = rpc.get_blockchain_info().await;
5285
5286 assert!(result.is_ok());
5287 let blockchain_info = result.unwrap();
5288 assert_eq!(blockchain_info.chain, "liquidregtest");
5289 assert_eq!(blockchain_info.blocks, 12345);
5290 assert_eq!(blockchain_info.bestblockhash, "abc123def456789");
5291
5292 mock.assert();
5293 }
5294
5295 #[tokio::test]
5296 async fn test_list_unspent_with_asset_filter() {
5297 let server = MockServer::start();
5298
5299 let mock_response = serde_json::json!({
5300 "jsonrpc": "1.0",
5301 "id": "amp-client",
5302 "result": [
5303 {
5304 "txid": "abc123def456789",
5305 "vout": 0,
5306 "amount": 100.0,
5307 "asset": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d",
5308 "address": "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
5309 "spendable": true,
5310 "confirmations": 6,
5311 "scriptpubkey": "76a914abc123def456789abc123def456789abc123de88ac"
5312 },
5313 {
5314 "txid": "def456abc123789",
5315 "vout": 1,
5316 "amount": 50.0,
5317 "asset": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d",
5318 "address": "lq1qq3xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
5319 "spendable": true,
5320 "confirmations": 3
5321 }
5322 ]
5323 });
5324
5325 let asset_id = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d";
5326
5327 let mock = server.mock(|when, then| {
5328 when.method(POST)
5329 .path("/")
5330 .header("authorization", "Basic dXNlcjpwYXNz")
5331 .json_body(serde_json::json!({
5332 "jsonrpc": "1.0",
5333 "id": "amp-client",
5334 "method": "listunspent",
5335 "params": [1, 9999999, [], true, {"asset": asset_id}]
5336 }));
5337 then.status(200)
5338 .header("content-type", "application/json")
5339 .json_body(mock_response);
5340 });
5341
5342 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5343 let result = rpc.list_unspent(Some(asset_id)).await;
5344
5345 assert!(result.is_ok());
5346 let utxos = result.unwrap();
5347 assert_eq!(utxos.len(), 2);
5348 assert_eq!(utxos[0].txid, "abc123def456789");
5349 assert_eq!(utxos[0].amount, 100.0);
5350 assert_eq!(utxos[0].asset, asset_id);
5351 assert_eq!(utxos[1].txid, "def456abc123789");
5352 assert_eq!(utxos[1].amount, 50.0);
5353
5354 mock.assert();
5355 }
5356
5357 #[tokio::test]
5358 async fn test_list_unspent_without_filter() {
5359 let server = MockServer::start();
5360
5361 let mock_response = serde_json::json!({
5362 "jsonrpc": "1.0",
5363 "id": "amp-client",
5364 "result": [
5365 {
5366 "txid": "ghi789jkl012345",
5367 "vout": 0,
5368 "amount": 25.0,
5369 "asset": "different_asset_id",
5370 "address": "lq1qq4xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
5371 "spendable": true,
5372 "confirmations": 10
5373 }
5374 ]
5375 });
5376
5377 let mock = server.mock(|when, then| {
5378 when.method(POST)
5379 .path("/")
5380 .header("authorization", "Basic dXNlcjpwYXNz")
5381 .json_body(serde_json::json!({
5382 "jsonrpc": "1.0",
5383 "id": "amp-client",
5384 "method": "listunspent",
5385 "params": [1, 9999999, [], true]
5386 }));
5387 then.status(200)
5388 .header("content-type", "application/json")
5389 .json_body(mock_response);
5390 });
5391
5392 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5393 let result = rpc.list_unspent(None).await;
5394
5395 assert!(result.is_ok());
5396 let utxos = result.unwrap();
5397 assert_eq!(utxos.len(), 1);
5398 assert_eq!(utxos[0].txid, "ghi789jkl012345");
5399 assert_eq!(utxos[0].amount, 25.0);
5400
5401 mock.assert();
5402 }
5403
5404 #[tokio::test]
5405 async fn test_create_raw_transaction_success() {
5406 let server = MockServer::start();
5407
5408 let mock_response = serde_json::json!({
5409 "jsonrpc": "1.0",
5410 "id": "amp-client",
5411 "result": "0200000000010abc123def456789abc123def456789abc123def456789abc123def456789abc123def456789000000006b483045022100..."
5412 });
5413
5414 let mock = server.mock(|when, then| {
5415 when.method(POST)
5416 .path("/")
5417 .header("authorization", "Basic dXNlcjpwYXNz")
5418 .json_body(serde_json::json!({
5419 "jsonrpc": "1.0",
5420 "id": "amp-client",
5421 "method": "createrawtransaction",
5422 "params": [
5423 [
5424 {
5425 "txid": "input_txid_123",
5426 "vout": 0,
5427 "sequence": 4294967295u32
5428 }
5429 ],
5430 {
5431 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq": 100.0
5432 },
5433 0,
5434 false,
5435 {
5436 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d"
5437 }
5438 ]
5439 }));
5440 then.status(200)
5441 .header("content-type", "application/json")
5442 .json_body(mock_response);
5443 });
5444
5445 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5446
5447 let inputs = vec![TxInput {
5448 txid: "input_txid_123".to_string(),
5449 vout: 0,
5450 sequence: Some(0xffffffff),
5451 }];
5452
5453 let mut outputs = HashMap::new();
5454 outputs.insert(
5455 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
5456 100.0,
5457 );
5458
5459 let mut assets = HashMap::new();
5460 assets.insert(
5461 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
5462 "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d".to_string(),
5463 );
5464
5465 let result = rpc.create_raw_transaction(inputs, outputs, assets).await;
5466
5467 assert!(result.is_ok());
5468 let raw_tx = result.unwrap();
5469 assert!(raw_tx.starts_with("0200000000010abc123def456789"));
5470
5471 mock.assert();
5472 }
5473
5474 #[tokio::test]
5475 async fn test_send_raw_transaction_success() {
5476 let server = MockServer::start();
5477
5478 let mock_response = serde_json::json!({
5479 "jsonrpc": "1.0",
5480 "id": "amp-client",
5481 "result": "abc123def456789abc123def456789abc123def456789abc123def456789abc123de"
5482 });
5483
5484 let signed_tx_hex = "0200000000010abc123def456789abc123def456789abc123def456789abc123def456789abc123def456789000000006b483045022100...";
5485
5486 let mock = server.mock(|when, then| {
5487 when.method(POST)
5488 .path("/")
5489 .header("authorization", "Basic dXNlcjpwYXNz")
5490 .json_body(serde_json::json!({
5491 "jsonrpc": "1.0",
5492 "id": "amp-client",
5493 "method": "sendrawtransaction",
5494 "params": [signed_tx_hex]
5495 }));
5496 then.status(200)
5497 .header("content-type", "application/json")
5498 .json_body(mock_response);
5499 });
5500
5501 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5502 let result = rpc.send_raw_transaction(signed_tx_hex).await;
5503
5504 assert!(result.is_ok());
5505 let txid = result.unwrap();
5506 assert_eq!(
5507 txid,
5508 "abc123def456789abc123def456789abc123def456789abc123def456789abc123de"
5509 );
5510
5511 mock.assert();
5512 }
5513
5514 #[tokio::test]
5515 async fn test_get_transaction_success() {
5516 let server = MockServer::start();
5517
5518 let mock_response = serde_json::json!({
5519 "jsonrpc": "1.0",
5520 "id": "amp-client",
5521 "result": {
5522 "txid": "abc123def456789abc123def456789abc123def456789abc123def456789abc123de",
5523 "confirmations": 6,
5524 "blockheight": 12345,
5525 "hex": "0200000000010abc123def456789...",
5526 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
5527 "blocktime": 1640995200,
5528 "time": 1640995200,
5529 "timereceived": 1640995180
5530 }
5531 });
5532
5533 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
5534
5535 let mock = server.mock(|when, then| {
5536 when.method(POST)
5537 .path("/")
5538 .header("authorization", "Basic dXNlcjpwYXNz")
5539 .json_body(serde_json::json!({
5540 "jsonrpc": "1.0",
5541 "id": "amp-client",
5542 "method": "gettransaction",
5543 "params": [txid, true]
5544 }));
5545 then.status(200)
5546 .header("content-type", "application/json")
5547 .json_body(mock_response);
5548 });
5549
5550 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5551 let result = rpc.get_transaction(txid).await;
5552
5553 assert!(result.is_ok());
5554 let tx_detail = result.unwrap();
5555 assert_eq!(tx_detail.txid, txid);
5556 assert_eq!(tx_detail.confirmations, 6);
5557 assert_eq!(tx_detail.blockheight, Some(12345));
5558 assert_eq!(tx_detail.blocktime, Some(1640995200));
5559
5560 mock.assert();
5561 }
5562
5563 // Error handling tests
5564
5565 #[tokio::test]
5566 async fn test_rpc_call_network_failure() {
5567 // Use an invalid URL to simulate network failure
5568 let rpc = ElementsRpc::new(
5569 "http://invalid-host:99999".to_string(),
5570 "user".to_string(),
5571 "pass".to_string(),
5572 );
5573
5574 let result = rpc.get_network_info().await;
5575 assert!(result.is_err());
5576
5577 match result.unwrap_err() {
5578 AmpError::Rpc(msg) => {
5579 assert!(msg.contains("Failed to send RPC request"));
5580 }
5581 _ => panic!("Expected RPC error for network failure"),
5582 }
5583 }
5584
5585 #[tokio::test]
5586 async fn test_rpc_call_http_error_status() {
5587 let server = MockServer::start();
5588
5589 let mock = server.mock(|when, then| {
5590 when.method(POST).path("/");
5591 then.status(500)
5592 .header("content-type", "application/json")
5593 .body("Internal Server Error");
5594 });
5595
5596 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5597 let result = rpc.get_network_info().await;
5598
5599 assert!(result.is_err());
5600 match result.unwrap_err() {
5601 AmpError::Rpc(msg) => {
5602 assert!(msg.contains("RPC request failed with status: 500"));
5603 }
5604 _ => panic!("Expected RPC error for HTTP error status"),
5605 }
5606
5607 mock.assert();
5608 }
5609
5610 #[tokio::test]
5611 async fn test_rpc_call_invalid_json_response() {
5612 let server = MockServer::start();
5613
5614 let mock = server.mock(|when, then| {
5615 when.method(POST).path("/");
5616 then.status(200)
5617 .header("content-type", "application/json")
5618 .body("invalid json response");
5619 });
5620
5621 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5622 let result = rpc.get_network_info().await;
5623
5624 assert!(result.is_err());
5625 match result.unwrap_err() {
5626 AmpError::Rpc(msg) => {
5627 assert!(msg.contains("Failed to parse RPC response"));
5628 }
5629 _ => panic!("Expected RPC error for invalid JSON"),
5630 }
5631
5632 mock.assert();
5633 }
5634
5635 #[tokio::test]
5636 async fn test_rpc_call_error_response() {
5637 let server = MockServer::start();
5638
5639 let mock_response = serde_json::json!({
5640 "jsonrpc": "1.0",
5641 "id": "amp-client",
5642 "result": null,
5643 "error": {
5644 "code": -32601,
5645 "message": "Method not found"
5646 }
5647 });
5648
5649 let mock = server.mock(|when, then| {
5650 when.method(POST).path("/");
5651 then.status(200)
5652 .header("content-type", "application/json")
5653 .json_body(mock_response);
5654 });
5655
5656 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5657 let result = rpc.get_network_info().await;
5658
5659 assert!(result.is_err());
5660 match result.unwrap_err() {
5661 AmpError::Rpc(msg) => {
5662 assert!(msg.contains("RPC error -32601: Method not found"));
5663 }
5664 _ => panic!("Expected RPC error for error response"),
5665 }
5666
5667 mock.assert();
5668 }
5669
5670 #[tokio::test]
5671 async fn test_rpc_call_missing_result() {
5672 let server = MockServer::start();
5673
5674 let mock_response = serde_json::json!({
5675 "jsonrpc": "1.0",
5676 "id": "amp-client",
5677 "result": null,
5678 "error": null
5679 });
5680
5681 let mock = server.mock(|when, then| {
5682 when.method(POST).path("/");
5683 then.status(200)
5684 .header("content-type", "application/json")
5685 .json_body(mock_response);
5686 });
5687
5688 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5689 let result = rpc.get_network_info().await;
5690
5691 assert!(result.is_err());
5692 match result.unwrap_err() {
5693 AmpError::Rpc(msg) => {
5694 assert!(msg.contains("RPC response missing result field"));
5695 }
5696 _ => panic!("Expected RPC error for missing result"),
5697 }
5698
5699 mock.assert();
5700 }
5701
5702 // Authentication tests
5703
5704 #[tokio::test]
5705 async fn test_rpc_authentication_headers() {
5706 let server = MockServer::start();
5707
5708 let mock_response = serde_json::json!({
5709 "jsonrpc": "1.0",
5710 "id": "amp-client",
5711 "result": {
5712 "version": 220000,
5713 "subversion": "/Liquid:22.0.0/",
5714 "protocolversion": 70016,
5715 "localservices": "0000000000000409",
5716 "localrelay": true,
5717 "timeoffset": 0,
5718 "networkactive": true,
5719 "connections": 8,
5720 "networks": [],
5721 "relayfee": 0.00001000,
5722 "incrementalfee": 0.00001000,
5723 "localaddresses": [],
5724 "warnings": ""
5725 }
5726 });
5727
5728 // Test with custom username and password
5729 let mock = server.mock(|when, then| {
5730 when.method(POST)
5731 .path("/")
5732 .header("authorization", "Basic dGVzdHVzZXI6dGVzdHBhc3M=") // base64 of "testuser:testpass"
5733 .json_body(serde_json::json!({
5734 "jsonrpc": "1.0",
5735 "id": "amp-client",
5736 "method": "getnetworkinfo",
5737 "params": []
5738 }));
5739 then.status(200)
5740 .header("content-type", "application/json")
5741 .json_body(mock_response);
5742 });
5743
5744 let rpc = ElementsRpc::new(
5745 server.url("/"),
5746 "testuser".to_string(),
5747 "testpass".to_string(),
5748 );
5749 let result = rpc.get_network_info().await;
5750
5751 assert!(result.is_ok());
5752 mock.assert();
5753 }
5754
5755 // Wallet passphrase tests
5756
5757 #[tokio::test]
5758 async fn test_wallet_passphrase_success() {
5759 let server = MockServer::start();
5760
5761 let mock_response = serde_json::json!({
5762 "jsonrpc": "1.0",
5763 "id": "amp-client",
5764 "result": null
5765 });
5766
5767 let mock = server.mock(|when, then| {
5768 when.method(POST)
5769 .path("/")
5770 .header("authorization", "Basic dXNlcjpwYXNz")
5771 .json_body(serde_json::json!({
5772 "jsonrpc": "1.0",
5773 "id": "amp-client",
5774 "method": "walletpassphrase",
5775 "params": ["my_passphrase", 300]
5776 }));
5777 then.status(200)
5778 .header("content-type", "application/json")
5779 .json_body(mock_response);
5780 });
5781
5782 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5783 let result = rpc.wallet_passphrase("my_passphrase", 300).await;
5784
5785 assert!(result.is_ok());
5786 mock.assert();
5787 }
5788
5789 // Connection validation tests
5790
5791 #[tokio::test]
5792 async fn test_validate_connection_success() {
5793 let server = MockServer::start();
5794
5795 let mock_response = serde_json::json!({
5796 "jsonrpc": "1.0",
5797 "id": "amp-client",
5798 "result": {
5799 "version": 220000,
5800 "subversion": "/Liquid:22.0.0/",
5801 "protocolversion": 70016,
5802 "localservices": "0000000000000409",
5803 "localrelay": true,
5804 "timeoffset": 0,
5805 "networkactive": true,
5806 "connections": 8,
5807 "networks": [],
5808 "relayfee": 0.00001000,
5809 "incrementalfee": 0.00001000,
5810 "localaddresses": [],
5811 "warnings": ""
5812 }
5813 });
5814
5815 let mock = server.mock(|when, then| {
5816 when.method(POST).path("/");
5817 then.status(200)
5818 .header("content-type", "application/json")
5819 .json_body(mock_response);
5820 });
5821
5822 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5823 let result = rpc.validate_connection().await;
5824
5825 assert!(result.is_ok());
5826 mock.assert();
5827 }
5828
5829 #[tokio::test]
5830 async fn test_get_node_status_success() {
5831 let server = MockServer::start();
5832
5833 let network_mock_response = serde_json::json!({
5834 "jsonrpc": "1.0",
5835 "id": "amp-client",
5836 "result": {
5837 "version": 220000,
5838 "subversion": "/Liquid:22.0.0/",
5839 "protocolversion": 70016,
5840 "localservices": "0000000000000409",
5841 "localrelay": true,
5842 "timeoffset": 0,
5843 "networkactive": true,
5844 "connections": 8,
5845 "networks": [],
5846 "relayfee": 0.00001000,
5847 "incrementalfee": 0.00001000,
5848 "localaddresses": [],
5849 "warnings": ""
5850 }
5851 });
5852
5853 let blockchain_mock_response = serde_json::json!({
5854 "jsonrpc": "1.0",
5855 "id": "amp-client",
5856 "result": {
5857 "chain": "liquidregtest",
5858 "blocks": 12345,
5859 "headers": 12345,
5860 "bestblockhash": "abc123def456789",
5861 "difficulty": 4.656542373906925e-10,
5862 "mediantime": 1640995200,
5863 "verificationprogress": 1.0,
5864 "initialblockdownload": false,
5865 "chainwork": "0000000000000000000000000000000000000000000000000000000000003039",
5866 "size_on_disk": 1234567,
5867 "pruned": false,
5868 "softforks": {},
5869 "warnings": ""
5870 }
5871 });
5872
5873 let network_mock = server.mock(|when, then| {
5874 when.method(POST).path("/").json_body(serde_json::json!({
5875 "jsonrpc": "1.0",
5876 "id": "amp-client",
5877 "method": "getnetworkinfo",
5878 "params": []
5879 }));
5880 then.status(200)
5881 .header("content-type", "application/json")
5882 .json_body(network_mock_response);
5883 });
5884
5885 let blockchain_mock = server.mock(|when, then| {
5886 when.method(POST).path("/").json_body(serde_json::json!({
5887 "jsonrpc": "1.0",
5888 "id": "amp-client",
5889 "method": "getblockchaininfo",
5890 "params": []
5891 }));
5892 then.status(200)
5893 .header("content-type", "application/json")
5894 .json_body(blockchain_mock_response);
5895 });
5896
5897 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5898 let result = rpc.get_node_status().await;
5899
5900 assert!(result.is_ok());
5901 let (network_info, blockchain_info) = result.unwrap();
5902 assert_eq!(network_info.version, 220000);
5903 assert_eq!(blockchain_info.blocks, 12345);
5904
5905 network_mock.assert();
5906 blockchain_mock.assert();
5907 }
5908
5909 // Tests for UTXO selection and transaction building logic
5910
5911 #[tokio::test]
5912 async fn test_build_distribution_transaction_zero_amount() {
5913 let rpc = ElementsRpc::new(
5914 "http://localhost:18884".to_string(),
5915 "user".to_string(),
5916 "pass".to_string(),
5917 );
5918
5919 let address_amounts = HashMap::new(); // Empty distribution
5920
5921 let result = rpc
5922 .build_distribution_transaction(
5923 "test_wallet",
5924 "asset_id",
5925 address_amounts,
5926 "change_address",
5927 1.0,
5928 )
5929 .await;
5930
5931 assert!(result.is_err());
5932 match result.unwrap_err() {
5933 AmpError::Validation(msg) => {
5934 assert!(msg.contains("Total distribution amount must be greater than zero"));
5935 }
5936 _ => panic!("Expected validation error for zero distribution amount"),
5937 }
5938 }
5939
5940 #[tokio::test]
5941 async fn test_sign_transaction_validation() {
5942 let rpc = ElementsRpc::new(
5943 "http://localhost:18884".to_string(),
5944 "user".to_string(),
5945 "pass".to_string(),
5946 );
5947
5948 // Mock signer for testing
5949 struct MockSigner {
5950 should_fail: bool,
5951 return_value: String,
5952 }
5953
5954 #[async_trait::async_trait]
5955 impl crate::signer::Signer for MockSigner {
5956 async fn sign_transaction(
5957 &self,
5958 _unsigned_tx: &str,
5959 ) -> Result<String, crate::signer::SignerError> {
5960 if self.should_fail {
5961 Err(crate::signer::SignerError::Lwk(
5962 "Mock signing failure".to_string(),
5963 ))
5964 } else {
5965 // Return a longer hex string to simulate signed transaction (20+ bytes when decoded)
5966 Ok(format!(
5967 "{}deadbeefcafebabe1234567890abcdef",
5968 self.return_value
5969 ))
5970 }
5971 }
5972
5973 fn as_any(&self) -> &dyn std::any::Any {
5974 self
5975 }
5976 }
5977
5978 // Test empty transaction hex
5979 let mock_signer = MockSigner {
5980 should_fail: false,
5981 return_value: "".to_string(),
5982 };
5983 let result = rpc.sign_transaction("", &mock_signer).await;
5984 assert!(result.is_err());
5985 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
5986
5987 // Test odd length hex
5988 let result = rpc.sign_transaction("abc", &mock_signer).await;
5989 assert!(result.is_err());
5990 assert!(result.unwrap_err().to_string().contains("even length"));
5991
5992 // Test invalid hex characters
5993 let result = rpc.sign_transaction("abcg", &mock_signer).await;
5994 assert!(result.is_err());
5995 assert!(result
5996 .unwrap_err()
5997 .to_string()
5998 .contains("invalid hex characters"));
5999
6000 // Test signer failure
6001 let mock_signer = MockSigner {
6002 should_fail: true,
6003 return_value: "".to_string(),
6004 };
6005 let result = rpc.sign_transaction("abcd", &mock_signer).await;
6006 assert!(result.is_err());
6007 assert!(result
6008 .unwrap_err()
6009 .to_string()
6010 .contains("Mock signing failure"));
6011
6012 // Test successful signing
6013 let mock_signer = MockSigner {
6014 should_fail: false,
6015 return_value: "abcd".to_string(),
6016 };
6017 let result = rpc.sign_transaction("abcd", &mock_signer).await;
6018 if result.is_err() {
6019 println!("Error: {}", result.as_ref().unwrap_err());
6020 }
6021 assert!(result.is_ok());
6022 assert_eq!(result.unwrap(), "abcddeadbeefcafebabe1234567890abcdef");
6023 }
6024
6025 #[tokio::test]
6026 async fn test_sign_transaction_validation_edge_cases() {
6027 let rpc = ElementsRpc::new(
6028 "http://localhost:18884".to_string(),
6029 "user".to_string(),
6030 "pass".to_string(),
6031 );
6032
6033 // Mock signer that returns invalid responses
6034 struct BadMockSigner {
6035 return_empty: bool,
6036 return_odd_length: bool,
6037 return_invalid_hex: bool,
6038 return_shorter: bool,
6039 }
6040
6041 #[async_trait::async_trait]
6042 impl crate::signer::Signer for BadMockSigner {
6043 async fn sign_transaction(
6044 &self,
6045 unsigned_tx: &str,
6046 ) -> Result<String, crate::signer::SignerError> {
6047 if self.return_empty {
6048 Ok("".to_string())
6049 } else if self.return_odd_length {
6050 Ok("abc".to_string())
6051 } else if self.return_invalid_hex {
6052 Ok("abcg".to_string())
6053 } else if self.return_shorter {
6054 Ok("ab".to_string()) // Shorter than input "abcd"
6055 } else {
6056 Ok(format!("{}deadbeef", unsigned_tx))
6057 }
6058 }
6059
6060 fn as_any(&self) -> &dyn std::any::Any {
6061 self
6062 }
6063 }
6064
6065 // Test signer returning empty string
6066 let bad_signer = BadMockSigner {
6067 return_empty: true,
6068 return_odd_length: false,
6069 return_invalid_hex: false,
6070 return_shorter: false,
6071 };
6072 let result = rpc.sign_transaction("abcd", &bad_signer).await;
6073 assert!(result.is_err());
6074 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
6075
6076 // Test signer returning odd length hex
6077 let bad_signer = BadMockSigner {
6078 return_empty: false,
6079 return_odd_length: true,
6080 return_invalid_hex: false,
6081 return_shorter: false,
6082 };
6083 let result = rpc.sign_transaction("abcd", &bad_signer).await;
6084 assert!(result.is_err());
6085 assert!(result.unwrap_err().to_string().contains("even length"));
6086
6087 // Test signer returning invalid hex
6088 let bad_signer = BadMockSigner {
6089 return_empty: false,
6090 return_odd_length: false,
6091 return_invalid_hex: true,
6092 return_shorter: false,
6093 };
6094 let result = rpc.sign_transaction("abcd", &bad_signer).await;
6095 assert!(result.is_err());
6096 assert!(result
6097 .unwrap_err()
6098 .to_string()
6099 .contains("invalid hex characters"));
6100
6101 // Test signer returning shorter transaction (invalid)
6102 let bad_signer = BadMockSigner {
6103 return_empty: false,
6104 return_odd_length: false,
6105 return_invalid_hex: false,
6106 return_shorter: true,
6107 };
6108 let result = rpc.sign_transaction("abcd", &bad_signer).await;
6109 assert!(result.is_err());
6110 assert!(result
6111 .unwrap_err()
6112 .to_string()
6113 .contains("shorter than unsigned transaction"));
6114 }
6115
6116 #[tokio::test]
6117 async fn test_sign_transaction_minimum_size_validation() {
6118 let rpc = ElementsRpc::new(
6119 "http://localhost:18884".to_string(),
6120 "user".to_string(),
6121 "pass".to_string(),
6122 );
6123
6124 // Mock signer that returns very small transactions
6125 struct TinyMockSigner;
6126
6127 #[async_trait::async_trait]
6128 impl crate::signer::Signer for TinyMockSigner {
6129 async fn sign_transaction(
6130 &self,
6131 _unsigned_tx: &str,
6132 ) -> Result<String, crate::signer::SignerError> {
6133 Ok("abcd".to_string()) // Only 2 bytes when decoded
6134 }
6135
6136 fn as_any(&self) -> &dyn std::any::Any {
6137 self
6138 }
6139 }
6140
6141 let tiny_signer = TinyMockSigner;
6142 let result = rpc.sign_transaction("abcd", &tiny_signer).await;
6143 assert!(result.is_err());
6144 let error_msg = result.unwrap_err().to_string();
6145 assert!(error_msg.contains("minimum size"));
6146 assert!(error_msg.contains("minimum is 10 bytes"));
6147 }
6148
6149 #[tokio::test]
6150 async fn test_sign_transaction_success_case() {
6151 let rpc = ElementsRpc::new(
6152 "http://localhost:18884".to_string(),
6153 "user".to_string(),
6154 "pass".to_string(),
6155 );
6156
6157 // Mock signer that returns a valid signed transaction
6158 struct GoodMockSigner;
6159
6160 #[async_trait::async_trait]
6161 impl crate::signer::Signer for GoodMockSigner {
6162 async fn sign_transaction(
6163 &self,
6164 unsigned_tx: &str,
6165 ) -> Result<String, crate::signer::SignerError> {
6166 // Return a longer valid hex string (20+ bytes when decoded)
6167 Ok(format!("{}deadbeefcafebabe1234567890abcdef", unsigned_tx))
6168 }
6169
6170 fn as_any(&self) -> &dyn std::any::Any {
6171 self
6172 }
6173 }
6174
6175 let good_signer = GoodMockSigner;
6176
6177 // Test with a reasonable sized unsigned transaction
6178 let unsigned_tx = "0200000000010123456789abcdef"; // 14 bytes when decoded
6179 let result = rpc.sign_transaction(unsigned_tx, &good_signer).await;
6180
6181 assert!(result.is_ok());
6182 let signed_tx = result.unwrap();
6183 assert!(signed_tx.starts_with(unsigned_tx));
6184 assert!(signed_tx.len() > unsigned_tx.len());
6185 assert!(signed_tx.contains("deadbeefcafebabe"));
6186 }
6187
6188 #[tokio::test]
6189 async fn test_sign_and_broadcast_transaction_mock() {
6190 // Create a mock server for testing the broadcast part
6191 let server = MockServer::start();
6192
6193 // Mock the RPC response for sendrawtransaction
6194 let mock = server.mock(|when, then| {
6195 when.method(POST).path("/").json_body(serde_json::json!({
6196 "jsonrpc": "1.0",
6197 "id": "amp-client",
6198 "method": "sendrawtransaction",
6199 "params": ["0200000000010123456789abcdefdeadbeefcafebabe1234567890abcdef"]
6200 }));
6201 then.status(200).json_body(serde_json::json!({
6202 "jsonrpc": "1.0",
6203 "id": "amp-client",
6204 "result": "abc123def456789",
6205 "error": null
6206 }));
6207 });
6208
6209 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6210
6211 // Mock signer for testing
6212 struct TestMockSigner;
6213
6214 #[async_trait::async_trait]
6215 impl crate::signer::Signer for TestMockSigner {
6216 async fn sign_transaction(
6217 &self,
6218 unsigned_tx: &str,
6219 ) -> Result<String, crate::signer::SignerError> {
6220 Ok(format!("{}deadbeefcafebabe1234567890abcdef", unsigned_tx))
6221 }
6222
6223 fn as_any(&self) -> &dyn std::any::Any {
6224 self
6225 }
6226 }
6227
6228 let signer = TestMockSigner;
6229 let unsigned_tx = "0200000000010123456789abcdef";
6230
6231 let result = rpc
6232 .sign_and_broadcast_transaction(unsigned_tx, &signer)
6233 .await;
6234
6235 assert!(result.is_ok());
6236 assert_eq!(result.unwrap(), "abc123def456789");
6237
6238 // Verify the mock was called
6239 mock.assert();
6240 }
6241
6242 #[tokio::test]
6243 async fn test_sign_and_broadcast_transaction_signing_failure() {
6244 let rpc = ElementsRpc::new(
6245 "http://localhost:18884".to_string(),
6246 "user".to_string(),
6247 "pass".to_string(),
6248 );
6249
6250 // Mock signer that fails
6251 struct FailingSigner;
6252
6253 #[async_trait::async_trait]
6254 impl crate::signer::Signer for FailingSigner {
6255 async fn sign_transaction(
6256 &self,
6257 _unsigned_tx: &str,
6258 ) -> Result<String, crate::signer::SignerError> {
6259 Err(crate::signer::SignerError::Lwk(
6260 "Signing failed".to_string(),
6261 ))
6262 }
6263
6264 fn as_any(&self) -> &dyn std::any::Any {
6265 self
6266 }
6267 }
6268
6269 let failing_signer = FailingSigner;
6270 let result = rpc
6271 .sign_and_broadcast_transaction("abcd", &failing_signer)
6272 .await;
6273
6274 assert!(result.is_err());
6275 let error_msg = result.unwrap_err().to_string();
6276 // The error should be a Signer error containing the original failure message
6277 assert!(error_msg.contains("Signer error"));
6278 assert!(error_msg.contains("Signing failed"));
6279 }
6280
6281 #[tokio::test]
6282 async fn test_sign_and_broadcast_transaction_broadcast_failure() {
6283 // Create a mock server that returns an error for broadcast
6284 let server = MockServer::start();
6285
6286 let mock = server.mock(|when, then| {
6287 when.method(POST).path("/");
6288 then.status(200).json_body(serde_json::json!({
6289 "jsonrpc": "1.0",
6290 "id": "amp-client",
6291 "result": null,
6292 "error": {
6293 "code": -26,
6294 "message": "Transaction rejected"
6295 }
6296 }));
6297 });
6298
6299 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6300
6301 // Mock signer that succeeds
6302 struct WorkingSigner;
6303
6304 #[async_trait::async_trait]
6305 impl crate::signer::Signer for WorkingSigner {
6306 async fn sign_transaction(
6307 &self,
6308 unsigned_tx: &str,
6309 ) -> Result<String, crate::signer::SignerError> {
6310 Ok(format!("{}deadbeefcafebabe1234567890abcdef", unsigned_tx))
6311 }
6312
6313 fn as_any(&self) -> &dyn std::any::Any {
6314 self
6315 }
6316 }
6317
6318 let working_signer = WorkingSigner;
6319 let unsigned_tx = "0200000000010123456789abcdef";
6320
6321 let result = rpc
6322 .sign_and_broadcast_transaction(unsigned_tx, &working_signer)
6323 .await;
6324
6325 assert!(result.is_err());
6326 let error_msg = result.unwrap_err().to_string();
6327 assert!(error_msg.contains("Failed during transaction broadcast phase"));
6328 assert!(error_msg.contains("Transaction rejected"));
6329
6330 mock.assert();
6331 }
6332
6333 #[tokio::test]
6334 async fn test_wait_for_confirmations_success() {
6335 let server = MockServer::start();
6336
6337 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
6338
6339 // First call returns 1 confirmation (not enough)
6340 let _mock_response_1 = serde_json::json!({
6341 "jsonrpc": "1.0",
6342 "id": "amp-client",
6343 "result": {
6344 "txid": txid,
6345 "confirmations": 1,
6346 "blockheight": 12345,
6347 "hex": "0200000000010abc123def456789...",
6348 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
6349 "blocktime": 1640995200,
6350 "time": 1640995200,
6351 "timereceived": 1640995180
6352 }
6353 });
6354
6355 // Second call returns 2 confirmations (sufficient)
6356 let mock_response_2 = serde_json::json!({
6357 "jsonrpc": "1.0",
6358 "id": "amp-client",
6359 "result": {
6360 "txid": txid,
6361 "confirmations": 2,
6362 "blockheight": 12345,
6363 "hex": "0200000000010abc123def456789...",
6364 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
6365 "blocktime": 1640995200,
6366 "time": 1640995200,
6367 "timereceived": 1640995180
6368 }
6369 });
6370
6371 // Create a mock that returns 2 confirmations immediately (simpler test)
6372 let mock = server.mock(|when, then| {
6373 when.method(POST)
6374 .path("/")
6375 .header("authorization", "Basic dXNlcjpwYXNz")
6376 .json_body(serde_json::json!({
6377 "jsonrpc": "1.0",
6378 "id": "amp-client",
6379 "method": "gettransaction",
6380 "params": [txid, true]
6381 }));
6382 then.status(200)
6383 .header("content-type", "application/json")
6384 .json_body(mock_response_2); // Return sufficient confirmations immediately
6385 });
6386
6387 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6388
6389 // Use fast polling (1 second) for testing
6390 let result = rpc
6391 .wait_for_confirmations_with_interval(txid, Some(2), Some(1), Some(1))
6392 .await;
6393
6394 assert!(result.is_ok());
6395 let tx_detail = result.unwrap();
6396 assert_eq!(tx_detail.confirmations, 2);
6397 assert_eq!(tx_detail.txid, txid);
6398
6399 // Mock should have been called once
6400 mock.assert();
6401 }
6402
6403 #[tokio::test]
6404 async fn test_wait_for_confirmations_timeout() {
6405 let server = MockServer::start();
6406
6407 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
6408
6409 // Always return insufficient confirmations
6410 let mock_response = serde_json::json!({
6411 "jsonrpc": "1.0",
6412 "id": "amp-client",
6413 "result": {
6414 "txid": txid,
6415 "confirmations": 1,
6416 "blockheight": 12345,
6417 "hex": "0200000000010abc123def456789...",
6418 "blockhash": null,
6419 "blocktime": null,
6420 "time": null,
6421 "timereceived": null
6422 }
6423 });
6424
6425 let _mock = server.mock(|when, then| {
6426 when.method(POST)
6427 .path("/")
6428 .header("authorization", "Basic dXNlcjpwYXNz")
6429 .json_body(serde_json::json!({
6430 "jsonrpc": "1.0",
6431 "id": "amp-client",
6432 "method": "gettransaction",
6433 "params": [txid, true]
6434 }));
6435 then.status(200)
6436 .header("content-type", "application/json")
6437 .json_body(mock_response);
6438 });
6439
6440 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6441
6442 // Use a very short timeout for testing (0 = 3 seconds) and fast polling (1 second)
6443 let result = rpc
6444 .wait_for_confirmations_with_interval(txid, Some(2), Some(0), Some(1))
6445 .await;
6446
6447 assert!(result.is_err());
6448 match result.unwrap_err() {
6449 AmpError::Timeout(msg) => {
6450 assert!(msg.contains("Timeout waiting for confirmations"));
6451 assert!(msg.contains(txid));
6452 assert!(msg.contains("retry confirmation"));
6453 }
6454 _ => panic!("Expected timeout error"),
6455 }
6456
6457 // Mock will be called multiple times during the timeout period
6458 // We don't assert on the exact number since it depends on timing
6459 }
6460
6461 #[tokio::test]
6462 async fn test_wait_for_confirmations_immediate_success() {
6463 let server = MockServer::start();
6464
6465 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
6466
6467 // Transaction already has sufficient confirmations
6468 let mock_response = serde_json::json!({
6469 "jsonrpc": "1.0",
6470 "id": "amp-client",
6471 "result": {
6472 "txid": txid,
6473 "confirmations": 5,
6474 "blockheight": 12345,
6475 "hex": "0200000000010abc123def456789...",
6476 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
6477 "blocktime": 1640995200,
6478 "time": 1640995200,
6479 "timereceived": 1640995180
6480 }
6481 });
6482
6483 let mock = server.mock(|when, then| {
6484 when.method(POST)
6485 .path("/")
6486 .header("authorization", "Basic dXNlcjpwYXNz")
6487 .json_body(serde_json::json!({
6488 "jsonrpc": "1.0",
6489 "id": "amp-client",
6490 "method": "gettransaction",
6491 "params": [txid, true]
6492 }));
6493 then.status(200)
6494 .header("content-type", "application/json")
6495 .json_body(mock_response);
6496 });
6497
6498 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6499
6500 let result = rpc.wait_for_confirmations(txid, Some(2), Some(10)).await;
6501
6502 assert!(result.is_ok());
6503 let tx_detail = result.unwrap();
6504 assert_eq!(tx_detail.confirmations, 5);
6505 assert_eq!(tx_detail.txid, txid);
6506
6507 // Should only need one call since confirmations are already sufficient
6508 mock.assert();
6509 }
6510}
6511
6512/// Configuration for retry behavior in API requests
6513#[derive(Debug, Clone)]
6514pub struct RetryConfig {
6515 /// Maximum number of retry attempts
6516 pub max_attempts: u32,
6517 /// Base delay in milliseconds for exponential backoff
6518 pub base_delay_ms: u64,
6519 /// Maximum delay in milliseconds to cap exponential backoff
6520 pub max_delay_ms: u64,
6521 /// Request timeout in seconds
6522 pub timeout_seconds: u64,
6523}
6524
6525impl Default for RetryConfig {
6526 fn default() -> Self {
6527 Self {
6528 max_attempts: 3,
6529 base_delay_ms: 1000,
6530 max_delay_ms: 30_000,
6531 timeout_seconds: 10,
6532 }
6533 }
6534}
6535
6536impl RetryConfig {
6537 /// Creates a `RetryConfig` from environment variables with default fallbacks
6538 ///
6539 /// Environment variables:
6540 /// - `API_RETRY_MAX_ATTEMPTS`: Maximum retry attempts (default: 3)
6541 /// - `API_RETRY_BASE_DELAY_MS`: Base delay in milliseconds (default: 1000)
6542 /// - `API_RETRY_MAX_DELAY_MS`: Maximum delay in milliseconds (default: 30000)
6543 /// - `API_REQUEST_TIMEOUT_SECONDS`: Request timeout in seconds (default: 10)
6544 ///
6545 /// # Errors
6546 ///
6547 /// Returns an error if any environment variable contains an invalid value
6548 pub fn from_env() -> Result<Self, Error> {
6549 let max_attempts = match env::var("API_RETRY_MAX_ATTEMPTS") {
6550 Ok(val) => val.parse::<u32>().map_err(|e| {
6551 Error::InvalidRetryConfig(format!("Invalid API_RETRY_MAX_ATTEMPTS: {e}"))
6552 })?,
6553 Err(_) => 3,
6554 };
6555
6556 let base_delay_ms = match env::var("API_RETRY_BASE_DELAY_MS") {
6557 Ok(val) => val.parse::<u64>().map_err(|e| {
6558 Error::InvalidRetryConfig(format!("Invalid API_RETRY_BASE_DELAY_MS: {e}"))
6559 })?,
6560 Err(_) => 1000,
6561 };
6562
6563 let max_delay_ms = match env::var("API_RETRY_MAX_DELAY_MS") {
6564 Ok(val) => val.parse::<u64>().map_err(|e| {
6565 Error::InvalidRetryConfig(format!("Invalid API_RETRY_MAX_DELAY_MS: {e}"))
6566 })?,
6567 Err(_) => 30_000,
6568 };
6569
6570 let timeout_seconds = match env::var("API_REQUEST_TIMEOUT_SECONDS") {
6571 Ok(val) => val.parse::<u64>().map_err(|e| {
6572 Error::InvalidRetryConfig(format!("Invalid API_REQUEST_TIMEOUT_SECONDS: {e}"))
6573 })?,
6574 Err(_) => 10,
6575 };
6576
6577 // Validate configuration
6578 if max_attempts == 0 {
6579 return Err(Error::InvalidRetryConfig(
6580 "max_attempts must be greater than 0".to_string(),
6581 ));
6582 }
6583 if base_delay_ms == 0 {
6584 return Err(Error::InvalidRetryConfig(
6585 "base_delay_ms must be greater than 0".to_string(),
6586 ));
6587 }
6588 if max_delay_ms < base_delay_ms {
6589 return Err(Error::InvalidRetryConfig(
6590 "max_delay_ms must be greater than or equal to base_delay_ms".to_string(),
6591 ));
6592 }
6593 if timeout_seconds == 0 {
6594 return Err(Error::InvalidRetryConfig(
6595 "timeout_seconds must be greater than 0".to_string(),
6596 ));
6597 }
6598
6599 Ok(Self {
6600 max_attempts,
6601 base_delay_ms,
6602 max_delay_ms,
6603 timeout_seconds,
6604 })
6605 }
6606
6607 /// Creates a `RetryConfig` optimized for test environments
6608 ///
6609 /// Uses reduced values for faster test execution:
6610 /// - 2 retry attempts
6611 /// - 500ms base delay
6612 /// - 5000ms max delay
6613 /// - 5 second timeout
6614 #[must_use]
6615 pub const fn for_tests() -> Self {
6616 Self {
6617 max_attempts: 2,
6618 base_delay_ms: 500,
6619 max_delay_ms: 5000,
6620 timeout_seconds: 5,
6621 }
6622 }
6623
6624 /// Sets a custom timeout value
6625 #[must_use]
6626 pub const fn with_timeout(mut self, timeout_seconds: u64) -> Self {
6627 self.timeout_seconds = timeout_seconds;
6628 self
6629 }
6630
6631 /// Sets custom max attempts
6632 #[must_use]
6633 pub const fn with_max_attempts(mut self, max_attempts: u32) -> Self {
6634 self.max_attempts = max_attempts;
6635 self
6636 }
6637
6638 /// Sets custom base delay
6639 #[must_use]
6640 pub const fn with_base_delay_ms(mut self, base_delay_ms: u64) -> Self {
6641 self.base_delay_ms = base_delay_ms;
6642 self
6643 }
6644
6645 /// Sets custom max delay
6646 #[must_use]
6647 pub const fn with_max_delay_ms(mut self, max_delay_ms: u64) -> Self {
6648 self.max_delay_ms = max_delay_ms;
6649 self
6650 }
6651}
6652
6653/// HTTP client with sophisticated retry logic and exponential backoff
6654#[derive(Debug, Clone)]
6655pub struct RetryClient {
6656 client: Client,
6657 config: RetryConfig,
6658}
6659
6660impl RetryClient {
6661 /// Creates a new `RetryClient` with the given configuration
6662 #[must_use]
6663 pub fn new(config: RetryConfig) -> Self {
6664 Self {
6665 client: Client::new(),
6666 config,
6667 }
6668 }
6669
6670 /// Creates a new `RetryClient` with default configuration
6671 #[must_use]
6672 pub fn with_default_config() -> Self {
6673 Self::new(RetryConfig::default())
6674 }
6675
6676 /// Creates a new `RetryClient` with test-optimized configuration
6677 #[must_use]
6678 pub fn for_tests() -> Self {
6679 Self::new(RetryConfig::for_tests())
6680 }
6681
6682 /// Executes an HTTP request with retry logic and exponential backoff
6683 ///
6684 /// # Arguments
6685 /// * `request_builder` - A function that creates the request builder
6686 ///
6687 /// # Returns
6688 /// The response if successful, or an error after all retries are exhausted
6689 ///
6690 /// # Errors
6691 /// Returns `TokenError::Timeout` if the request times out
6692 /// Returns `TokenError::RateLimited` if rate limited and retries are exhausted
6693 /// Returns `TokenError::ObtainFailed` if all retry attempts fail
6694 #[allow(clippy::cognitive_complexity)]
6695 pub async fn execute_with_retry<F>(
6696 &self,
6697 request_builder: F,
6698 ) -> Result<reqwest::Response, TokenError>
6699 where
6700 F: Fn() -> reqwest::RequestBuilder + Send + Sync,
6701 {
6702 let mut last_error = String::new();
6703 let mut attempt = 0;
6704
6705 while attempt < self.config.max_attempts {
6706 attempt += 1;
6707
6708 // Create the request with timeout
6709 let request =
6710 request_builder().timeout(StdDuration::from_secs(self.config.timeout_seconds));
6711
6712 // Execute the request
6713 match request.send().await {
6714 Ok(response) => {
6715 let status = response.status();
6716
6717 // Handle rate limiting (429 Too Many Requests)
6718 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
6719 let retry_after = Self::extract_retry_after(&response).unwrap_or(60);
6720
6721 tracing::warn!(
6722 "Rate limited (429) on attempt {}/{}. Retry after {} seconds",
6723 attempt,
6724 self.config.max_attempts,
6725 retry_after
6726 );
6727
6728 // If this is our last attempt, return the rate limit error
6729 if attempt >= self.config.max_attempts {
6730 return Err(TokenError::rate_limited(retry_after));
6731 }
6732
6733 // Wait for the rate limit period (or our max delay, whichever is smaller)
6734 let delay_ms = std::cmp::min(retry_after * 1000, self.config.max_delay_ms);
6735 sleep(StdDuration::from_millis(delay_ms)).await;
6736 continue;
6737 }
6738
6739 // Handle other client errors (4xx) - these are generally not retryable
6740 if status.is_client_error() && status != reqwest::StatusCode::TOO_MANY_REQUESTS
6741 {
6742 last_error = format!("Client error: {status}");
6743 tracing::error!("Non-retryable client error: {}", status);
6744 break;
6745 }
6746
6747 // Handle server errors (5xx) - these are retryable
6748 if status.is_server_error() {
6749 last_error = format!("Server error: {status}");
6750 tracing::warn!(
6751 "Server error {} on attempt {}/{}",
6752 status,
6753 attempt,
6754 self.config.max_attempts
6755 );
6756
6757 if attempt < self.config.max_attempts {
6758 let delay = self.calculate_backoff_delay(attempt);
6759 sleep(delay).await;
6760 continue;
6761 }
6762 break;
6763 }
6764
6765 // Success case
6766 return Ok(response);
6767 }
6768 Err(e) => {
6769 last_error = e.to_string();
6770
6771 // Check if this is a timeout error
6772 if e.is_timeout() {
6773 tracing::warn!(
6774 "Request timeout on attempt {}/{}",
6775 attempt,
6776 self.config.max_attempts
6777 );
6778
6779 if attempt >= self.config.max_attempts {
6780 return Err(TokenError::timeout(self.config.timeout_seconds));
6781 }
6782 } else {
6783 tracing::warn!(
6784 "Request failed on attempt {}/{}: {}",
6785 attempt,
6786 self.config.max_attempts,
6787 e
6788 );
6789 }
6790
6791 // If we have more attempts, wait and retry
6792 if attempt < self.config.max_attempts {
6793 let delay = self.calculate_backoff_delay(attempt);
6794 sleep(delay).await;
6795 }
6796 }
6797 }
6798 }
6799
6800 // All retries exhausted
6801 Err(TokenError::obtain_failed(attempt, last_error))
6802 }
6803
6804 /// Calculates the delay for exponential backoff with jitter
6805 ///
6806 /// Uses the formula: `min(base_delay * 2^(attempt-1) + jitter, max_delay)`
6807 /// where jitter is a random value between 0 and `base_delay/2`
6808 pub fn calculate_backoff_delay(&self, attempt: u32) -> StdDuration {
6809 use rand::Rng;
6810
6811 let base_delay = self.config.base_delay_ms;
6812 let max_delay = self.config.max_delay_ms;
6813
6814 // Calculate exponential backoff: base_delay * 2^(attempt-1)
6815 let exponential_delay = base_delay * 2_u64.pow(attempt.saturating_sub(1));
6816
6817 // Add jitter (random value between 0 and base_delay/2)
6818 let jitter = rand::thread_rng().gen_range(0..=base_delay / 2);
6819 let total_delay = exponential_delay + jitter;
6820
6821 // Cap at max_delay
6822 let final_delay = std::cmp::min(total_delay, max_delay);
6823
6824 tracing::debug!(
6825 "Calculated backoff delay for attempt {}: {}ms (exponential: {}ms, jitter: {}ms, capped at: {}ms)",
6826 attempt,
6827 final_delay,
6828 exponential_delay,
6829 jitter,
6830 max_delay
6831 );
6832
6833 StdDuration::from_millis(final_delay)
6834 }
6835
6836 /// Extracts the Retry-After header value from a 429 response
6837 ///
6838 /// Returns the number of seconds to wait, or None if the header is not present
6839 /// or cannot be parsed
6840 fn extract_retry_after(response: &reqwest::Response) -> Option<u64> {
6841 response
6842 .headers()
6843 .get("retry-after")
6844 .and_then(|value| value.to_str().ok())
6845 .and_then(|s| s.parse::<u64>().ok())
6846 }
6847
6848 /// Gets the underlying reqwest client
6849 #[must_use]
6850 pub const fn client(&self) -> &Client {
6851 &self.client
6852 }
6853
6854 /// Gets the retry configuration
6855 #[must_use]
6856 pub const fn config(&self) -> &RetryConfig {
6857 &self.config
6858 }
6859}
6860
6861/// Singleton instance of the `TokenManager` for shared token storage across all `ApiClient` instances
6862static GLOBAL_TOKEN_MANAGER: OnceCell<Arc<TokenManager>> = OnceCell::const_new();
6863
6864/// Core token manager with proactive refresh and secure storage
6865#[derive(Debug)]
6866pub struct TokenManager {
6867 pub token_data: Arc<Mutex<Option<TokenData>>>,
6868 pub retry_client: RetryClient,
6869 base_url: Url,
6870 /// Semaphore to ensure only one token operation (obtain/refresh) happens at a time
6871 /// This prevents race conditions where multiple threads try to refresh/obtain simultaneously
6872 token_operation_semaphore: Arc<Semaphore>,
6873}
6874
6875impl TokenManager {
6876 /// Gets the global singleton instance of `TokenManager`
6877 ///
6878 /// This ensures all `ApiClient` instances share the same token storage,
6879 /// preventing multiple token acquisition attempts in concurrent tests.
6880 ///
6881 /// # Errors
6882 /// Returns an error if the `TokenManager` cannot be initialized
6883 pub async fn get_global_instance() -> Result<Arc<Self>, Error> {
6884 let manager = GLOBAL_TOKEN_MANAGER
6885 .get_or_try_init(|| async {
6886 let config = RetryConfig::from_env()?;
6887 let base_url = get_amp_api_base_url()?;
6888 let manager = Self::with_config_and_base_url(config, base_url).await?;
6889 Ok::<Arc<Self>, Error>(Arc::new(manager))
6890 })
6891 .await?;
6892
6893 Ok(manager.clone())
6894 }
6895
6896 /// Creates a new `TokenManager` with default configuration
6897 ///
6898 /// # Errors
6899 /// Returns an error if the base URL cannot be obtained from environment variables
6900 pub async fn new() -> Result<Self, Error> {
6901 let config = RetryConfig::from_env()?;
6902 Self::with_config(config).await
6903 }
6904
6905 /// Creates a new `TokenManager` with the specified retry configuration
6906 ///
6907 /// # Errors
6908 /// Returns an error if the base URL cannot be obtained from environment variables
6909 pub async fn with_config(config: RetryConfig) -> Result<Self, Error> {
6910 let base_url = get_amp_api_base_url()?;
6911 Self::with_config_and_base_url(config, base_url).await
6912 }
6913
6914 /// Creates a new `TokenManager` with the specified configuration and base URL (for testing)
6915 ///
6916 /// # Errors
6917 /// This method is infallible but returns Result for API consistency
6918 pub async fn with_config_and_base_url(
6919 config: RetryConfig,
6920 base_url: Url,
6921 ) -> Result<Self, Error> {
6922 let manager = Self {
6923 token_data: Arc::new(Mutex::new(None)),
6924 retry_client: RetryClient::new(config),
6925 base_url,
6926 token_operation_semaphore: Arc::new(Semaphore::new(1)),
6927 };
6928
6929 // Load token from disk if persistence is enabled
6930 if Self::should_persist_tokens() {
6931 if let Ok(Some(token_data)) = manager.load_token_from_disk().await {
6932 *manager.token_data.lock().await = Some(token_data);
6933 tracing::info!("Token loaded from disk during initialization");
6934 }
6935 }
6936
6937 Ok(manager)
6938 }
6939
6940 /// Creates a new `TokenManager` with a pre-set mock token (for testing)
6941 ///
6942 /// # Errors
6943 /// This method is infallible but returns Result for API consistency
6944 pub fn with_mock_token(
6945 config: RetryConfig,
6946 base_url: Url,
6947 mock_token: String,
6948 ) -> Result<Self, Error> {
6949 let expires_at = Utc::now() + Duration::hours(24); // Mock token valid for 24 hours
6950 let token_data = TokenData::new(mock_token, expires_at);
6951
6952 let manager = Self {
6953 token_data: Arc::new(Mutex::new(Some(token_data))),
6954 retry_client: RetryClient::new(config),
6955 base_url,
6956 token_operation_semaphore: Arc::new(Semaphore::new(1)),
6957 };
6958
6959 Ok(manager)
6960 }
6961
6962 /// Gets a valid authentication token with proactive refresh logic
6963 ///
6964 /// This method implements thread-safe token management logic:
6965 /// 1. Check if a valid token exists and is not expiring soon (within 5 minutes)
6966 /// 2. If token needs refresh/obtain, acquire semaphore to prevent concurrent operations
6967 /// 3. Double-check token state after acquiring semaphore (another thread may have updated it)
6968 /// 4. Perform atomic token update operations
6969 /// 5. Return the valid token
6970 ///
6971 /// # Thread Safety
6972 /// This method is fully thread-safe and prevents race conditions by:
6973 /// - Using a semaphore to ensure only one token operation at a time
6974 /// - Double-checking token state after acquiring the semaphore
6975 /// - Performing atomic token updates within the critical section
6976 ///
6977 /// # Errors
6978 /// Returns a `TokenError` if token acquisition or refresh fails after all retries
6979 pub async fn get_token(&self) -> Result<String, Error> {
6980 // Fast path: check if we have a valid token without acquiring semaphore
6981 if let Some(token) = self.check_existing_token().await? {
6982 return Ok(token);
6983 }
6984
6985 // Slow path: token needs refresh/obtain, acquire semaphore for thread safety
6986 let _permit = self.acquire_token_semaphore().await?;
6987
6988 // Double-check token state after acquiring semaphore - another thread may have updated it
6989 if let Some(token) = self.check_existing_token().await? {
6990 tracing::debug!("Token was updated by another thread, using existing valid token");
6991 return Ok(token);
6992 }
6993
6994 // At this point, we need to refresh or obtain a new token
6995 self.handle_token_refresh_or_obtain().await
6996 }
6997
6998 /// Checks if we have a valid existing token that doesn't expire soon
6999 async fn check_existing_token(&self) -> Result<Option<String>, Error> {
7000 let token_guard = self.token_data.lock().await;
7001 if let Some(ref token_data) = *token_guard {
7002 if !token_data.expires_soon(Duration::minutes(5)) {
7003 tracing::debug!("Using existing valid token");
7004 let token = token_data.token.expose_secret().clone();
7005 drop(token_guard);
7006 return Ok(Some(token));
7007 }
7008 }
7009 drop(token_guard);
7010 Ok(None)
7011 }
7012
7013 /// Acquires the token operation semaphore for thread-safe operations
7014 async fn acquire_token_semaphore(&self) -> Result<tokio::sync::SemaphorePermit<'_>, Error> {
7015 let permit = self
7016 .token_operation_semaphore
7017 .acquire()
7018 .await
7019 .map_err(|e| {
7020 Error::Token(TokenError::storage(format!(
7021 "Failed to acquire token operation semaphore: {e}"
7022 )))
7023 })?;
7024
7025 tracing::debug!("Acquired token operation semaphore for thread-safe token management");
7026 Ok(permit)
7027 }
7028
7029 /// Handles the token refresh or obtain logic
7030 async fn handle_token_refresh_or_obtain(&self) -> Result<String, Error> {
7031 let needs_refresh = self.determine_token_operation().await;
7032
7033 if needs_refresh {
7034 match self.refresh_token_internal().await {
7035 Ok(token) => {
7036 tracing::info!("Token refreshed successfully");
7037 return Ok(token);
7038 }
7039 Err(e) => {
7040 tracing::warn!("Token refresh failed, falling back to obtain: {e}");
7041 // Fall through to obtain new token
7042 }
7043 }
7044 }
7045
7046 // Either we needed to obtain from the start, or refresh failed
7047 self.obtain_token_internal().await
7048 }
7049
7050 /// Determines whether we need to refresh or obtain a new token
7051 async fn determine_token_operation(&self) -> bool {
7052 let token_guard = self.token_data.lock().await;
7053 token_guard.as_ref().map_or_else(
7054 || {
7055 tracing::info!("No token exists, will obtain new token");
7056 false
7057 },
7058 |token_data| {
7059 if token_data.is_expired() {
7060 tracing::info!("Token is expired, will obtain new token");
7061 false
7062 } else {
7063 tracing::info!("Token expires soon, will attempt refresh");
7064 true
7065 }
7066 },
7067 )
7068 }
7069
7070 /// Obtains a new authentication token using environment credentials with retry logic
7071 ///
7072 /// This method:
7073 /// 1. Reads credentials from environment variables
7074 /// 2. Makes a token request with retry logic
7075 /// 3. Stores the new token with 24-hour expiry
7076 /// 4. Returns the token string
7077 ///
7078 /// # Thread Safety
7079 /// This method acquires the token operation semaphore to ensure thread-safe operation.
7080 /// For internal use within already-synchronized contexts, use `obtain_token_internal()`.
7081 ///
7082 /// # Errors
7083 /// Returns an error if:
7084 /// - Environment variables are missing
7085 /// - All retry attempts fail
7086 /// - Response parsing fails
7087 pub async fn obtain_token(&self) -> Result<String, Error> {
7088 let _permit = self
7089 .token_operation_semaphore
7090 .acquire()
7091 .await
7092 .map_err(|e| {
7093 Error::Token(TokenError::storage(format!(
7094 "Failed to acquire token operation semaphore: {e}"
7095 )))
7096 })?;
7097
7098 self.obtain_token_internal().await
7099 }
7100
7101 /// Internal method to obtain a new authentication token without acquiring semaphore
7102 ///
7103 /// This method should only be called from contexts where the token operation semaphore
7104 /// has already been acquired (e.g., from within `get_token()`).
7105 ///
7106 /// # Errors
7107 /// Returns an error if:
7108 /// - Environment variables are missing
7109 /// - All retry attempts fail
7110 /// - Response parsing fails
7111 async fn obtain_token_internal(&self) -> Result<String, Error> {
7112 tracing::debug!("Obtaining new authentication token");
7113
7114 let request_payload = Self::get_credentials_from_env()?;
7115 let url = self.build_obtain_token_url();
7116 let response = self.execute_token_request(&url, &request_payload).await?;
7117 let token_response = self.parse_token_response(response).await?;
7118
7119 self.store_token_data(&token_response.token).await;
7120
7121 tracing::info!("New authentication token obtained successfully");
7122 Ok(token_response.token)
7123 }
7124
7125 /// Gets credentials from environment variables
7126 fn get_credentials_from_env() -> Result<TokenRequest, Error> {
7127 let username = env::var("AMP_USERNAME")
7128 .map_err(|_| Error::MissingEnvVar("AMP_USERNAME".to_string()))?;
7129 let password = env::var("AMP_PASSWORD")
7130 .map_err(|_| Error::MissingEnvVar("AMP_PASSWORD".to_string()))?;
7131
7132 Ok(TokenRequest { username, password })
7133 }
7134
7135 /// Builds the URL for token obtain endpoint
7136 fn build_obtain_token_url(&self) -> Url {
7137 let mut url = self.base_url.clone();
7138 url.path_segments_mut()
7139 .unwrap()
7140 .push("user")
7141 .push("obtain_token");
7142 url
7143 }
7144
7145 /// Executes the token request with retry logic
7146 async fn execute_token_request(
7147 &self,
7148 url: &Url,
7149 request_payload: &TokenRequest,
7150 ) -> Result<reqwest::Response, Error> {
7151 let response = self
7152 .retry_client
7153 .execute_with_retry(|| {
7154 self.retry_client
7155 .client()
7156 .post(url.clone())
7157 .json(request_payload)
7158 })
7159 .await
7160 .map_err(Error::Token)?;
7161
7162 if !response.status().is_success() {
7163 let status = response.status();
7164 let error_text = response
7165 .text()
7166 .await
7167 .unwrap_or_else(|_| "Unknown error".to_string());
7168 return Err(Error::TokenRequestFailed { status, error_text });
7169 }
7170
7171 Ok(response)
7172 }
7173
7174 /// Parses the token response from the API
7175 async fn parse_token_response(
7176 &self,
7177 response: reqwest::Response,
7178 ) -> Result<TokenResponse, Error> {
7179 response
7180 .json()
7181 .await
7182 .map_err(|e| Error::ResponseParsingFailed(e.to_string()))
7183 }
7184
7185 /// Stores the token data with 24-hour expiry and optional disk persistence
7186 async fn store_token_data(&self, token: &str) {
7187 let expires_at = Utc::now() + Duration::days(1);
7188 let token_data = TokenData::new(token.to_string(), expires_at);
7189
7190 // Atomic token update - hold the lock for the minimal time needed
7191 *self.token_data.lock().await = Some(token_data.clone());
7192 tracing::debug!("Token data updated atomically in storage");
7193
7194 // Save to disk if persistence is enabled
7195 if Self::should_persist_tokens() {
7196 if let Err(e) = self.save_token_to_disk(&token_data).await {
7197 tracing::warn!("Failed to save token to disk: {e}");
7198 }
7199 }
7200 }
7201
7202 /// Refreshes the current authentication token with fallback to obtain on failure
7203 ///
7204 /// This method:
7205 /// 1. Uses the existing token to request a refresh
7206 /// 2. Updates the stored token data on success
7207 /// 3. Falls back to obtaining a new token if refresh fails
7208 ///
7209 /// # Thread Safety
7210 /// This method acquires the token operation semaphore to ensure thread-safe operation.
7211 /// For internal use within already-synchronized contexts, use `refresh_token_internal()`.
7212 ///
7213 /// # Errors
7214 /// Returns an error if both refresh and obtain operations fail
7215 pub async fn refresh_token(&self) -> Result<String, Error> {
7216 let _permit = self
7217 .token_operation_semaphore
7218 .acquire()
7219 .await
7220 .map_err(|e| {
7221 Error::Token(TokenError::storage(format!(
7222 "Failed to acquire token operation semaphore: {e}"
7223 )))
7224 })?;
7225
7226 self.refresh_token_internal().await
7227 }
7228
7229 /// Internal method to refresh the current authentication token without acquiring semaphore
7230 ///
7231 /// This method should only be called from contexts where the token operation semaphore
7232 /// has already been acquired (e.g., from within `get_token()`).
7233 ///
7234 /// # Errors
7235 /// Returns an error if both refresh and obtain operations fail
7236 #[allow(clippy::cognitive_complexity)]
7237 async fn refresh_token_internal(&self) -> Result<String, Error> {
7238 tracing::debug!("Refreshing authentication token");
7239
7240 let Some(current_token) = self.get_current_token_for_refresh().await else {
7241 tracing::warn!("No token available for refresh, obtaining new token");
7242 return self.obtain_token_internal().await;
7243 };
7244
7245 let url = self.build_refresh_token_url();
7246 let response = self.execute_refresh_request(&url, ¤t_token).await;
7247
7248 match response {
7249 Ok(resp) => self.handle_refresh_response(resp).await,
7250 Err(e) => {
7251 tracing::warn!("Token refresh request failed: {e}, falling back to obtain");
7252 self.obtain_token_internal().await
7253 }
7254 }
7255 }
7256
7257 /// Gets the current token for refresh operations
7258 async fn get_current_token_for_refresh(&self) -> Option<String> {
7259 let token_guard = self.token_data.lock().await;
7260 token_guard
7261 .as_ref()
7262 .map(|token_data| token_data.token.expose_secret().clone())
7263 }
7264
7265 /// Builds the URL for token refresh endpoint
7266 fn build_refresh_token_url(&self) -> Url {
7267 let mut url = self.base_url.clone();
7268 url.path_segments_mut()
7269 .unwrap()
7270 .push("user")
7271 .push("refresh_token");
7272 url
7273 }
7274
7275 /// Executes the refresh request with retry logic
7276 async fn execute_refresh_request(
7277 &self,
7278 url: &Url,
7279 current_token: &str,
7280 ) -> Result<reqwest::Response, TokenError> {
7281 self.retry_client
7282 .execute_with_retry(|| {
7283 self.retry_client
7284 .client()
7285 .post(url.clone())
7286 .header(AUTHORIZATION, format!("token {current_token}"))
7287 })
7288 .await
7289 }
7290
7291 /// Handles the refresh response, either storing the new token or falling back to obtain
7292 async fn handle_refresh_response(&self, resp: reqwest::Response) -> Result<String, Error> {
7293 if !resp.status().is_success() {
7294 let status = resp.status();
7295 let error_text = resp
7296 .text()
7297 .await
7298 .unwrap_or_else(|_| "Unknown error".to_string());
7299
7300 tracing::warn!("Token refresh failed with status {status}: {error_text}");
7301 return self.obtain_token_internal().await;
7302 }
7303
7304 let token_response: TokenResponse = resp
7305 .json()
7306 .await
7307 .map_err(|e| Error::ResponseParsingFailed(e.to_string()))?;
7308
7309 self.store_token_data(&token_response.token).await;
7310 tracing::info!("Authentication token refreshed successfully");
7311 Ok(token_response.token)
7312 }
7313
7314 /// Gets current token information for debugging and monitoring
7315 ///
7316 /// Returns detailed information about the current token including:
7317 /// - Expiry time and remaining duration
7318 /// - Token age since acquisition
7319 /// - Expiry status flags
7320 ///
7321 /// # Returns
7322 /// `Some(TokenInfo)` if a token exists, `None` if no token is stored
7323 ///
7324 /// # Errors
7325 /// Returns an error if token information retrieval fails
7326 pub async fn get_token_info(&self) -> Result<Option<TokenInfo>, Error> {
7327 tracing::debug!("Retrieving token information for debugging");
7328
7329 let token_info = self.token_data.lock().await.as_ref().map(TokenInfo::from);
7330
7331 match &token_info {
7332 Some(info) => {
7333 tracing::debug!(
7334 "Token info retrieved - expires_at: {}, age: {:?}, expires_in: {:?}, is_expired: {}, expires_soon: {}",
7335 info.expires_at,
7336 info.age,
7337 info.expires_in,
7338 info.is_expired,
7339 info.expires_soon
7340 );
7341 }
7342 None => {
7343 tracing::debug!("No token information available - no token stored");
7344 }
7345 }
7346
7347 Ok(token_info)
7348 }
7349
7350 /// Clears the stored token (useful for testing scenarios)
7351 ///
7352 /// This method removes the current token from storage, forcing the next
7353 /// `get_token()` call to obtain a fresh token.
7354 ///
7355 /// # Errors
7356 /// Returns an error if token clearing fails
7357 pub async fn clear_token(&self) -> Result<(), Error> {
7358 tracing::debug!("Clearing stored token from memory and disk");
7359
7360 let had_token = self.clear_token_from_memory().await;
7361 self.clear_token_from_disk_if_enabled().await;
7362 Self::log_token_clear_result(had_token);
7363
7364 Ok(())
7365 }
7366
7367 /// Clears the token from memory and returns whether a token was present
7368 async fn clear_token_from_memory(&self) -> bool {
7369 let mut token_guard = self.token_data.lock().await;
7370 let had_token = token_guard.is_some();
7371 *token_guard = None;
7372 drop(token_guard);
7373 had_token
7374 }
7375
7376 /// Clears the token from disk if persistence is enabled
7377 async fn clear_token_from_disk_if_enabled(&self) {
7378 if Self::should_persist_tokens() {
7379 if let Err(e) = self.remove_token_from_disk().await {
7380 tracing::warn!("Failed to remove token from disk: {e}");
7381 }
7382 }
7383 }
7384
7385 /// Logs the result of the token clearing operation
7386 fn log_token_clear_result(had_token: bool) {
7387 if had_token {
7388 tracing::info!("Token successfully cleared from memory and disk - next get_token() will obtain fresh token");
7389 } else {
7390 tracing::debug!("No token was stored to clear");
7391 }
7392 }
7393
7394 /// Forces a token refresh regardless of current token status
7395 ///
7396 /// This method bypasses the normal proactive refresh logic and immediately
7397 /// attempts to refresh the current token. If no token exists or refresh fails,
7398 /// it falls back to obtaining a new token.
7399 ///
7400 /// # Thread Safety
7401 /// This method is fully thread-safe and uses the same semaphore-based synchronization
7402 /// as other token operations to prevent race conditions.
7403 ///
7404 /// # Errors
7405 /// Returns an error if both refresh and obtain operations fail
7406 pub async fn force_refresh(&self) -> Result<String, Error> {
7407 tracing::info!("Forcing token refresh - bypassing normal proactive refresh logic");
7408
7409 let _permit = self.acquire_token_semaphore().await?;
7410 self.log_token_status_for_refresh().await;
7411 self.execute_forced_refresh().await
7412 }
7413
7414 /// Logs the current token status for forced refresh operation
7415 async fn log_token_status_for_refresh(&self) {
7416 let has_token = {
7417 let token_guard = self.token_data.lock().await;
7418 token_guard.is_some()
7419 };
7420
7421 if has_token {
7422 tracing::debug!("Existing token found, attempting forced refresh");
7423 } else {
7424 tracing::debug!("No existing token found, will obtain new token");
7425 }
7426 }
7427
7428 /// Executes the forced refresh operation
7429 async fn execute_forced_refresh(&self) -> Result<String, Error> {
7430 match self.refresh_token_internal().await {
7431 Ok(token) => {
7432 tracing::info!("Forced token refresh completed successfully");
7433 Ok(token)
7434 }
7435 Err(e) => {
7436 tracing::error!("Forced token refresh failed: {e}");
7437 Err(e)
7438 }
7439 }
7440 }
7441
7442 /// Determines if token persistence is enabled based on environment variables
7443 ///
7444 /// Token persistence is enabled when:
7445 /// - `AMP_TESTS=live` (for live API testing)
7446 /// - `AMP_TOKEN_PERSISTENCE=true` is set
7447 /// - NOT in mock test environments (to prevent test pollution)
7448 fn should_persist_tokens() -> bool {
7449 // Use the new environment detection logic
7450 let environment = TokenEnvironment::detect();
7451
7452 // Never persist tokens in mock environments to prevent test pollution
7453 if environment.is_mock() {
7454 tracing::debug!("Token persistence disabled - mock environment detected");
7455 return false;
7456 }
7457
7458 // Check if explicitly enabled
7459 if env::var("AMP_TOKEN_PERSISTENCE").unwrap_or_default() == "true" {
7460 tracing::debug!("Token persistence enabled - AMP_TOKEN_PERSISTENCE=true");
7461 return true;
7462 }
7463
7464 // Use environment-based persistence setting
7465 let should_persist = environment.should_persist_tokens();
7466 tracing::debug!(
7467 "Token persistence setting from environment: {}",
7468 should_persist
7469 );
7470 should_persist
7471 }
7472
7473 /// Loads token data from disk if it exists and is valid
7474 async fn load_token_from_disk(&self) -> Result<Option<TokenData>, Error> {
7475 let token_file = "token.json";
7476
7477 if !self.token_file_exists(token_file).await {
7478 return Ok(None);
7479 }
7480
7481 let content = self.read_token_file(token_file).await?;
7482 self.parse_and_validate_token(token_file, &content).await
7483 }
7484
7485 /// Checks if the token file exists on disk
7486 async fn token_file_exists(&self, token_file: &str) -> bool {
7487 tokio::fs::try_exists(token_file).await.map_or_else(
7488 |_| {
7489 tracing::debug!("Error checking token file existence: {}", token_file);
7490 false
7491 },
7492 |exists| {
7493 if !exists {
7494 tracing::debug!("Token file does not exist: {}", token_file);
7495 }
7496 exists
7497 },
7498 )
7499 }
7500
7501 /// Reads the token file content from disk
7502 async fn read_token_file(&self, token_file: &str) -> Result<String, Error> {
7503 use tokio::fs;
7504
7505 match fs::read_to_string(token_file).await {
7506 Ok(content) => Ok(content),
7507 Err(e) => {
7508 tracing::warn!("Failed to read token file: {e}");
7509 Err(Error::Token(TokenError::storage(format!(
7510 "Failed to read token file: {e}"
7511 ))))
7512 }
7513 }
7514 }
7515
7516 /// Parses token content and validates expiration
7517 async fn parse_and_validate_token(
7518 &self,
7519 token_file: &str,
7520 content: &str,
7521 ) -> Result<Option<TokenData>, Error> {
7522 match serde_json::from_str::<TokenData>(content) {
7523 Ok(token_data) => self.handle_parsed_token(token_file, token_data).await,
7524 Err(e) => self.handle_parse_error(token_file, e).await,
7525 }
7526 }
7527
7528 /// Handles successfully parsed token data, checking expiration
7529 async fn handle_parsed_token(
7530 &self,
7531 token_file: &str,
7532 token_data: TokenData,
7533 ) -> Result<Option<TokenData>, Error> {
7534 if token_data.is_expired() {
7535 tracing::info!("Token loaded from disk is expired, removing file");
7536 let _ = tokio::fs::remove_file(token_file).await;
7537 Ok(None)
7538 } else {
7539 tracing::info!("Valid token loaded from disk");
7540 Ok(Some(token_data))
7541 }
7542 }
7543
7544 /// Handles token parsing errors by cleaning up the invalid file
7545 async fn handle_parse_error(
7546 &self,
7547 token_file: &str,
7548 e: serde_json::Error,
7549 ) -> Result<Option<TokenData>, Error> {
7550 tracing::warn!("Failed to parse token file, removing: {e}");
7551 let _ = tokio::fs::remove_file(token_file).await;
7552 Err(Error::Token(TokenError::serialization(format!(
7553 "Failed to parse token file: {e}"
7554 ))))
7555 }
7556
7557 /// Saves token data to disk
7558 async fn save_token_to_disk(&self, token_data: &TokenData) -> Result<(), Error> {
7559 use tokio::fs;
7560
7561 let token_file = "token.json";
7562
7563 match serde_json::to_string_pretty(token_data) {
7564 Ok(json) => match fs::write(token_file, json).await {
7565 Ok(()) => {
7566 tracing::debug!("Token saved to disk: {}", token_file);
7567 Ok(())
7568 }
7569 Err(e) => {
7570 tracing::error!("Failed to write token file: {e}");
7571 Err(Error::Token(TokenError::storage(format!(
7572 "Failed to write token file: {e}"
7573 ))))
7574 }
7575 },
7576 Err(e) => {
7577 tracing::error!("Failed to serialize token data: {e}");
7578 Err(Error::Token(TokenError::serialization(format!(
7579 "Failed to serialize token data: {e}"
7580 ))))
7581 }
7582 }
7583 }
7584
7585 /// Removes the token file from disk
7586 async fn remove_token_from_disk(&self) -> Result<(), Error> {
7587 use tokio::fs;
7588
7589 let token_file = "token.json";
7590
7591 match fs::remove_file(token_file).await {
7592 Ok(()) => {
7593 tracing::debug!("Token file removed from disk: {}", token_file);
7594 Ok(())
7595 }
7596 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7597 tracing::debug!("Token file does not exist, nothing to remove");
7598 Ok(())
7599 }
7600 Err(e) => {
7601 tracing::warn!("Failed to remove token file: {e}");
7602 Err(Error::Token(TokenError::storage(format!(
7603 "Failed to remove token file: {e}"
7604 ))))
7605 }
7606 }
7607 }
7608
7609 /// Forces cleanup of token persistence files (useful for testing)
7610 /// This method removes token files regardless of persistence settings
7611 ///
7612 /// # Errors
7613 /// Returns an error if:
7614 /// - File system permissions prevent deletion of the token file
7615 /// - I/O errors occur during file deletion operations
7616 /// - The token file is locked by another process
7617 pub async fn force_cleanup_token_files() -> Result<(), Error> {
7618 use tokio::fs;
7619
7620 let token_file = "token.json";
7621
7622 match fs::remove_file(token_file).await {
7623 Ok(()) => {
7624 tracing::debug!("Token file forcefully removed: {}", token_file);
7625 Ok(())
7626 }
7627 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7628 tracing::debug!("No token file to clean up");
7629 Ok(())
7630 }
7631 Err(e) => {
7632 tracing::warn!("Failed to force cleanup token file: {e}");
7633 Err(Error::Token(TokenError::storage(format!(
7634 "Failed to force cleanup token file: {e}"
7635 ))))
7636 }
7637 }
7638 }
7639
7640 /// Resets the global `TokenManager` singleton (useful for testing)
7641 ///
7642 /// This method clears the global singleton instance, forcing the next
7643 /// call to `get_global_instance()` to create a fresh `TokenManager`.
7644 /// Primarily intended for test scenarios where a clean state is needed.
7645 ///
7646 /// # Errors
7647 /// Returns an error if:
7648 /// - Token clearing operations fail during the reset process
7649 /// - File system errors occur when clearing persistent token data
7650 /// - The global instance is in an invalid state that prevents cleanup
7651 pub async fn reset_global_instance() -> Result<(), Error> {
7652 // Clear any existing token from the current global instance
7653 if let Some(manager) = GLOBAL_TOKEN_MANAGER.get() {
7654 let _ = manager.clear_token().await;
7655 }
7656
7657 // Reset the OnceCell to allow a new instance to be created
7658 // Note: OnceCell doesn't have a reset method, so we can't actually reset it
7659 // The best we can do is clear the token from the existing instance
7660 tracing::debug!("Global TokenManager instance token cleared for testing");
7661 Ok(())
7662 }
7663}
7664
7665#[derive(Debug)]
7666pub struct ApiClient {
7667 client: Client,
7668 base_url: Url,
7669 token_strategy: Box<dyn TokenStrategy>,
7670}
7671
7672#[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
7673impl ApiClient {
7674 /// Creates a new API client with the base URL from environment variables.
7675 ///
7676 /// Automatically selects the appropriate token strategy based on environment detection:
7677 /// - Mock strategy for mock environments (no persistence, isolated tokens)
7678 /// - Live strategy for live environments (full token management with persistence)
7679 ///
7680 /// # Errors
7681 ///
7682 /// Returns an error if:
7683 /// - The `AMP_API_BASE_URL` environment variable contains an invalid URL
7684 /// - Token strategy initialization fails
7685 ///
7686 /// # Examples
7687 /// ```no_run
7688 /// # use amp_rs::ApiClient;
7689 /// # #[tokio::main]
7690 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7691 /// // Create a new client - automatically detects environment
7692 /// let client = ApiClient::new().await?;
7693 ///
7694 /// // Client is ready to use
7695 /// let assets = client.get_assets().await?;
7696 /// println!("Found {} assets", assets.len());
7697 /// # Ok(())
7698 /// # }
7699 /// ```
7700 pub async fn new() -> Result<Self, Error> {
7701 let base_url = get_amp_api_base_url()?;
7702 let client = Client::new();
7703
7704 // Automatic strategy selection based on environment
7705 let token_strategy = TokenEnvironment::create_auto_strategy(None).await?;
7706
7707 tracing::info!(
7708 "Created ApiClient with {} strategy for base URL: {}",
7709 token_strategy.strategy_type(),
7710 base_url
7711 );
7712
7713 Ok(Self {
7714 client,
7715 base_url,
7716 token_strategy,
7717 })
7718 }
7719
7720 /// Creates a new API client with the specified base URL.
7721 ///
7722 /// Automatically selects the appropriate token strategy based on environment detection.
7723 ///
7724 /// # Errors
7725 ///
7726 /// Returns an error if token strategy initialization fails.
7727 ///
7728 /// # Examples
7729 /// ```no_run
7730 /// # use amp_rs::ApiClient;
7731 /// # use reqwest::Url;
7732 /// # #[tokio::main]
7733 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7734 /// let base_url = Url::parse("https://amp-test.blockstream.com/api")?;
7735 /// let client = ApiClient::with_base_url(base_url).await?;
7736 ///
7737 /// // Client is ready to use with the specified URL
7738 /// let assets = client.get_assets().await?;
7739 /// # Ok(())
7740 /// # }
7741 /// ```
7742 pub async fn with_base_url(base_url: Url) -> Result<Self, Error> {
7743 let client = Client::new();
7744
7745 // Automatic strategy selection based on environment
7746 let token_strategy = TokenEnvironment::create_auto_strategy(None).await?;
7747
7748 tracing::info!(
7749 "Created ApiClient with {} strategy for base URL: {}",
7750 token_strategy.strategy_type(),
7751 base_url
7752 );
7753
7754 Ok(Self {
7755 client,
7756 base_url,
7757 token_strategy,
7758 })
7759 }
7760
7761 /// Creates a new API client with a custom token strategy (useful for testing).
7762 ///
7763 /// # Errors
7764 ///
7765 /// Returns an error if the base URL cannot be obtained from environment variables.
7766 pub fn with_token_strategy(token_strategy: Box<dyn TokenStrategy>) -> Result<Self, Error> {
7767 let base_url = get_amp_api_base_url()?;
7768
7769 tracing::info!(
7770 "Created ApiClient with explicit {} strategy for base URL: {}",
7771 token_strategy.strategy_type(),
7772 base_url
7773 );
7774
7775 Ok(Self {
7776 client: Client::new(),
7777 base_url,
7778 token_strategy,
7779 })
7780 }
7781
7782 /// Creates a new API client with a custom token manager (useful for testing).
7783 ///
7784 /// # Errors
7785 ///
7786 /// Returns an error if the base URL cannot be obtained from environment variables.
7787 pub fn with_token_manager(token_manager: Arc<TokenManager>) -> Result<Self, Error> {
7788 let base_url = get_amp_api_base_url()?;
7789 let token_strategy: Box<dyn TokenStrategy> =
7790 Box::new(LiveTokenStrategy::with_token_manager(token_manager));
7791
7792 tracing::info!(
7793 "Created ApiClient with custom token manager for base URL: {}",
7794 base_url
7795 );
7796
7797 Ok(Self {
7798 client: Client::new(),
7799 base_url,
7800 token_strategy,
7801 })
7802 }
7803
7804 /// Creates a new API client for testing with a mock token strategy that always returns a fixed token.
7805 /// This bypasses all token acquisition and management logic and uses complete isolation.
7806 ///
7807 /// # Errors
7808 ///
7809 /// This method is infallible but returns Result for API consistency.
7810 ///
7811 /// # Examples
7812 /// ```
7813 /// # use amp_rs::ApiClient;
7814 /// # use reqwest::Url;
7815 /// # #[tokio::main]
7816 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7817 /// let base_url = Url::parse("http://localhost:8080/api")?;
7818 /// let client = ApiClient::with_mock_token(base_url, "test_token".to_string())?;
7819 ///
7820 /// // Client will always use "test_token" for authentication
7821 /// let token = client.get_token().await?;
7822 /// assert_eq!(token, "test_token");
7823 /// # Ok(())
7824 /// # }
7825 /// ```
7826 pub fn with_mock_token(base_url: Url, mock_token: String) -> Result<Self, Error> {
7827 let client = Client::new();
7828 let token_strategy: Box<dyn TokenStrategy> = Box::new(MockTokenStrategy::new(mock_token));
7829
7830 tracing::info!(
7831 "Created ApiClient with explicit mock token strategy for base URL: {}",
7832 base_url
7833 );
7834
7835 Ok(Self {
7836 client,
7837 base_url,
7838 token_strategy,
7839 })
7840 }
7841
7842 /// Obtains a new authentication token from the AMP API.
7843 ///
7844 /// **Note**: This method is deprecated in favor of the automatic token management
7845 /// provided by `get_token()`. The `TokenManager` handles token acquisition internally
7846 /// with enhanced retry logic and error handling.
7847 ///
7848 /// # Errors
7849 ///
7850 /// Returns an error if:
7851 /// - The `AMP_USERNAME` or `AMP_PASSWORD` environment variables are not set
7852 /// - The HTTP request fails
7853 /// - The token request is rejected by the server
7854 /// - The response cannot be parsed
7855 #[deprecated(note = "Use get_token() instead - it provides automatic token management")]
7856 pub async fn obtain_amp_token(&self) -> Result<String, Error> {
7857 // Delegate to get_token for backward compatibility
7858 self.get_token().await
7859 }
7860
7861 /// Gets current token information for debugging and monitoring.
7862 ///
7863 /// Returns detailed information about the current token including:
7864 /// - Expiry time and remaining duration
7865 /// - Token age since acquisition
7866 /// - Expiry status flags
7867 ///
7868 /// Note: Mock strategies may return limited or no token information.
7869 ///
7870 /// # Returns
7871 /// `Some(TokenInfo)` if a token exists, `None` if no token is stored or strategy doesn't support info
7872 ///
7873 /// # Errors
7874 /// Returns an error if token information retrieval fails
7875 ///
7876 /// # Examples
7877 /// ```no_run
7878 /// # use amp_rs::ApiClient;
7879 /// # #[tokio::main]
7880 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7881 /// let client = ApiClient::new().await?;
7882 ///
7883 /// if let Some(token_info) = client.get_token_info().await? {
7884 /// println!("Token expires at: {}", token_info.expires_at);
7885 /// println!("Token is expired: {}", token_info.is_expired);
7886 /// } else {
7887 /// println!("No token stored or mock strategy in use");
7888 /// }
7889 /// # Ok(())
7890 /// # }
7891 /// ```
7892 pub async fn get_token_info(&self) -> Result<Option<TokenInfo>, Error> {
7893 // Only live strategies support detailed token information
7894 if let Some(live_strategy) = self
7895 .token_strategy
7896 .as_any()
7897 .downcast_ref::<LiveTokenStrategy>()
7898 {
7899 live_strategy.get_token_info().await
7900 } else {
7901 // Mock strategies don't provide detailed token information
7902 tracing::debug!(
7903 "Token info not available for {} strategy",
7904 self.token_strategy.strategy_type()
7905 );
7906 Ok(None)
7907 }
7908 }
7909
7910 /// Clears the stored token (useful for testing scenarios).
7911 ///
7912 /// This method removes the current token from storage, forcing the next
7913 /// `get_token()` call to obtain a fresh token.
7914 ///
7915 /// # Errors
7916 /// Returns an error if token clearing fails
7917 ///
7918 /// # Examples
7919 /// ```no_run
7920 /// # use amp_rs::ApiClient;
7921 /// # #[tokio::main]
7922 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7923 /// let client = ApiClient::new().await?;
7924 ///
7925 /// // Clear any existing token
7926 /// client.clear_token().await?;
7927 ///
7928 /// // Next get_token() call will obtain a fresh token
7929 /// let token = client.get_token().await?;
7930 /// # Ok(())
7931 /// # }
7932 /// ```
7933 pub async fn clear_token(&self) -> Result<(), Error> {
7934 self.token_strategy.clear_token().await
7935 }
7936
7937 /// Forces a token refresh regardless of current token status.
7938 ///
7939 /// This method bypasses the normal proactive refresh logic and immediately
7940 /// attempts to refresh the current token. If no token exists or refresh fails,
7941 /// it falls back to obtaining a new token.
7942 ///
7943 /// # Errors
7944 /// Returns an error if both refresh and obtain operations fail
7945 pub async fn force_refresh(&self) -> Result<String, Error> {
7946 // Clear current token and get a fresh one
7947 self.token_strategy.clear_token().await?;
7948 self.token_strategy.get_token().await
7949 }
7950
7951 /// Resets the global `TokenManager` singleton (useful for testing).
7952 ///
7953 /// This method clears the token from the global `TokenManager` instance.
7954 /// Primarily intended for test scenarios where a clean token state is needed.
7955 ///
7956 /// # Errors
7957 /// Returns an error if the reset operation fails
7958 pub async fn reset_global_token_manager() -> Result<(), Error> {
7959 TokenManager::reset_global_instance().await
7960 }
7961
7962 /// Gets a valid authentication token with automatic token management.
7963 ///
7964 /// This method uses the integrated `TokenManager` to handle:
7965 /// - Proactive token refresh (5 minutes before expiry)
7966 /// - Automatic fallback from refresh to obtain on failure
7967 /// - Retry logic with exponential backoff
7968 /// - Thread-safe token storage
7969 ///
7970 /// # Errors
7971 ///
7972 /// Returns an error if token acquisition or refresh fails after all retries.
7973 ///
7974 /// # Examples
7975 /// ```no_run
7976 /// # use amp_rs::ApiClient;
7977 /// # #[tokio::main]
7978 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7979 /// let client = ApiClient::new().await?;
7980 ///
7981 /// // Get a valid token - automatically handles refresh if needed
7982 /// let token = client.get_token().await?;
7983 /// println!("Got token: {}", &token[..10]); // Print first 10 chars
7984 /// # Ok(())
7985 /// # }
7986 /// ```
7987 pub async fn get_token(&self) -> Result<String, Error> {
7988 self.token_strategy.get_token().await
7989 }
7990
7991 /// Returns the type of token strategy currently in use
7992 ///
7993 /// This is useful for debugging and testing to verify the correct strategy is selected.
7994 ///
7995 /// # Returns
7996 /// A string indicating the strategy type: "mock" or "live"
7997 #[must_use]
7998 pub fn get_strategy_type(&self) -> &'static str {
7999 self.token_strategy.strategy_type()
8000 }
8001
8002 /// Returns whether the current strategy persists tokens
8003 ///
8004 /// This is useful for understanding the token management behavior.
8005 ///
8006 /// # Returns
8007 /// `true` if tokens are persisted to disk, `false` for in-memory only
8008 #[must_use]
8009 pub fn should_persist_tokens(&self) -> bool {
8010 self.token_strategy.should_persist()
8011 }
8012
8013 /// Force cleanup of token files (for test cleanup)
8014 ///
8015 /// This is a static method that can be used to cleanup token files
8016 /// without needing an `ApiClient` instance. Useful for test teardown.
8017 ///
8018 /// # Errors
8019 /// Returns an error if token file cleanup fails
8020 pub async fn force_cleanup_token_files() -> Result<(), Error> {
8021 // Only cleanup if we're not in a live test environment
8022 let environment = TokenEnvironment::detect();
8023 if !environment.is_live() || environment.is_mock() {
8024 TokenManager::force_cleanup_token_files().await?;
8025 tracing::debug!("Token files cleaned up for non-live environment");
8026 } else {
8027 tracing::debug!("Skipping token file cleanup in live environment");
8028 }
8029 Ok(())
8030 }
8031
8032 async fn request_raw(
8033 &self,
8034 method: Method,
8035 path: &[&str],
8036 body: Option<impl serde::Serialize>,
8037 ) -> Result<reqwest::Response, Error> {
8038 let debug_logging = std::env::var("AMP_DEBUG").is_ok();
8039
8040 if debug_logging {
8041 eprintln!("🌐 HTTP Request: {} /{}", method, path.join("/"));
8042 }
8043
8044 let token = self.get_token().await?;
8045 let mut url = self.base_url.clone();
8046 url.path_segments_mut().unwrap().extend(path);
8047
8048 if debug_logging {
8049 eprintln!("🔗 Full URL: {url}");
8050 }
8051
8052 // Retry logic for network issues
8053 let max_retries = 3;
8054 let mut last_error = None;
8055
8056 for attempt in 1..=max_retries {
8057 if debug_logging && attempt > 1 {
8058 eprintln!("🔄 Retry attempt {attempt} of {max_retries}");
8059 }
8060
8061 let mut request_builder = self
8062 .client
8063 .request(method.clone(), url.clone())
8064 .header(AUTHORIZATION, format!("token {token}"))
8065 .timeout(std::time::Duration::from_secs(60)); // Increase timeout to 60 seconds
8066
8067 if let Some(ref body) = body {
8068 if debug_logging && attempt == 1 {
8069 if let Ok(json_body) = serde_json::to_string_pretty(&body) {
8070 eprintln!(
8071 "📤 Request body ({} bytes):\n{}",
8072 json_body.len(),
8073 json_body
8074 );
8075 } else {
8076 eprintln!("📤 Request body: [serialization failed]");
8077 }
8078 }
8079 request_builder = request_builder.json(&body);
8080 } else if debug_logging && attempt == 1 {
8081 eprintln!("📤 Request body: [empty]");
8082 }
8083
8084 if debug_logging {
8085 eprintln!("🚀 Sending HTTP request (attempt {attempt})...");
8086 }
8087
8088 match request_builder.send().await {
8089 Ok(response) => {
8090 let status = response.status();
8091
8092 if debug_logging {
8093 eprintln!("📥 Response status: {status}");
8094 }
8095
8096 if !status.is_success() {
8097 let error_text = response
8098 .text()
8099 .await
8100 .unwrap_or_else(|_| "Unknown error".to_string());
8101
8102 if debug_logging {
8103 eprintln!("❌ Error response body: {error_text}");
8104 }
8105
8106 return Err(Error::RequestFailed(format!(
8107 "Request to {path:?} failed with status {status}: {error_text}"
8108 )));
8109 }
8110
8111 if debug_logging {
8112 eprintln!("✅ HTTP request successful");
8113 }
8114
8115 return Ok(response);
8116 }
8117 Err(e) => {
8118 if debug_logging {
8119 eprintln!("❌ HTTP request failed (attempt {attempt}): {e:?}");
8120 eprintln!(" Error kind: {:?}", e.is_timeout());
8121 eprintln!(" Is connect error: {}", e.is_connect());
8122 eprintln!(" Is request error: {}", e.is_request());
8123 }
8124
8125 last_error = Some(e);
8126
8127 // Only retry on network/connection errors, not on client errors
8128 if attempt < max_retries {
8129 #[allow(clippy::cast_sign_loss)] // attempt is always positive (1-3)
8130 let delay = std::time::Duration::from_millis((attempt as u64) * 1000);
8131 if debug_logging {
8132 eprintln!("⏳ Waiting {}ms before retry...", delay.as_millis());
8133 }
8134 tokio::time::sleep(delay).await;
8135 }
8136 }
8137 }
8138 }
8139
8140 // If we get here, all retries failed
8141 if debug_logging {
8142 eprintln!("❌ All {max_retries} retry attempts failed");
8143 }
8144
8145 Err(Error::Reqwest(last_error.unwrap()))
8146 }
8147
8148 async fn request_json<T: DeserializeOwned>(
8149 &self,
8150 method: Method,
8151 path: &[&str],
8152 body: Option<impl serde::Serialize>,
8153 ) -> Result<T, Error> {
8154 let response = self.request_raw(method, path, body).await?;
8155 response
8156 .json()
8157 .await
8158 .map_err(|e| Error::ResponseParsingFailed(e.to_string()))
8159 }
8160
8161 async fn request_empty(
8162 &self,
8163 method: Method,
8164 path: &[&str],
8165 body: Option<impl serde::Serialize>,
8166 ) -> Result<(), Error> {
8167 self.request_raw(method, path, body).await?;
8168 Ok(())
8169 }
8170
8171 /// Gets the API changelog.
8172 ///
8173 /// # Errors
8174 ///
8175 /// Returns an error if:
8176 /// - Authentication fails
8177 /// - The HTTP request fails
8178 /// - The server returns an error status
8179 /// - The response cannot be parsed as JSON
8180 pub async fn get_changelog(&self) -> Result<serde_json::Value, Error> {
8181 self.request_json(Method::GET, &["changelog"], None::<&()>)
8182 .await
8183 }
8184
8185 /// Changes the user's password.
8186 ///
8187 /// # Errors
8188 ///
8189 /// Returns an error if:
8190 /// - Authentication fails
8191 /// - The HTTP request fails
8192 /// - The server rejects the password change
8193 /// - The response cannot be parsed
8194 pub async fn user_change_password(
8195 &self,
8196 password: Secret<String>,
8197 ) -> Result<ChangePasswordResponse, Error> {
8198 let request = ChangePasswordRequest {
8199 password: Secret::new(Password(password.expose_secret().clone())),
8200 };
8201 self.request_json(Method::POST, &["user", "change_password"], Some(request))
8202 .await
8203 }
8204
8205 /// Gets a list of all assets.
8206 ///
8207 /// # Errors
8208 ///
8209 /// Returns an error if:
8210 /// - Authentication fails
8211 /// - The HTTP request fails
8212 /// - The server returns an error status
8213 /// - The response cannot be parsed
8214 ///
8215 /// # Examples
8216 /// ```no_run
8217 /// # use amp_rs::ApiClient;
8218 /// # #[tokio::main]
8219 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8220 /// let client = ApiClient::new().await?;
8221 ///
8222 /// let assets = client.get_assets().await?;
8223 /// for asset in assets {
8224 /// println!("Asset: {} ({})", asset.name, asset.ticker.unwrap_or_default());
8225 /// }
8226 /// # Ok(())
8227 /// # }
8228 /// ```
8229 pub async fn get_assets(&self) -> Result<Vec<Asset>, Error> {
8230 self.request_json(Method::GET, &["assets"], None::<&()>)
8231 .await
8232 }
8233
8234 /// Gets a specific asset by UUID.
8235 ///
8236 /// # Errors
8237 ///
8238 /// Returns an error if:
8239 /// - Authentication fails
8240 /// - The HTTP request fails
8241 /// - The asset does not exist
8242 /// - The response cannot be parsed
8243 ///
8244 /// # Examples
8245 /// ```no_run
8246 /// # use amp_rs::ApiClient;
8247 /// # #[tokio::main]
8248 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8249 /// let client = ApiClient::new().await?;
8250 ///
8251 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
8252 /// let asset = client.get_asset(asset_uuid).await?;
8253 ///
8254 /// println!("Asset: {} ({})", asset.name, asset.ticker.unwrap_or_default());
8255 /// println!("Registered: {}, Locked: {}", asset.is_registered, asset.is_locked);
8256 /// # Ok(())
8257 /// # }
8258 /// ```
8259 pub async fn get_asset(&self, asset_uuid: &str) -> Result<Asset, Error> {
8260 self.request_json(Method::GET, &["assets", asset_uuid], None::<&()>)
8261 .await
8262 }
8263
8264 /// Issues a new asset.
8265 ///
8266 /// # Errors
8267 ///
8268 /// Returns an error if:
8269 /// - Authentication fails
8270 /// - The HTTP request fails
8271 /// - The issuance request is invalid
8272 /// - The response cannot be parsed
8273 ///
8274 /// # Examples
8275 /// ```no_run
8276 /// # use amp_rs::{ApiClient, model::IssuanceRequest};
8277 /// # #[tokio::main]
8278 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8279 /// let client = ApiClient::new().await?;
8280 ///
8281 /// let issuance_request = IssuanceRequest {
8282 /// name: "My Token".to_string(),
8283 /// amount: 1000000,
8284 /// destination_address: "vjU2i2EM2viGEzSywpStMPkTX9U9QSDsLSN63kJJYVpxKJZuxaph8v5r5Jf11aqnfBVdjSbrvcJ2pw26".to_string(),
8285 /// domain: "example.com".to_string(),
8286 /// ticker: "MYTKN".to_string(),
8287 /// pubkey: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
8288 /// precision: Some(8),
8289 /// is_confidential: Some(true),
8290 /// is_reissuable: Some(false),
8291 /// reissuance_amount: None,
8292 /// reissuance_address: None,
8293 /// transfer_restricted: Some(false),
8294 /// };
8295 ///
8296 /// let response = client.issue_asset(&issuance_request).await?;
8297 /// println!("Issued asset with UUID: {}", response.asset_uuid);
8298 /// # Ok(())
8299 /// # }
8300 /// ```
8301 pub async fn issue_asset(
8302 &self,
8303 issuance_request: &IssuanceRequest,
8304 ) -> Result<IssuanceResponse, Error> {
8305 self.request_json(Method::POST, &["assets", "issue"], Some(issuance_request))
8306 .await
8307 }
8308
8309 /// Edits an existing asset.
8310 ///
8311 /// # Errors
8312 ///
8313 /// Returns an error if:
8314 /// - Authentication fails
8315 /// - The HTTP request fails
8316 /// - The asset does not exist
8317 /// - The edit request is invalid
8318 /// - The response cannot be parsed
8319 pub async fn edit_asset(
8320 &self,
8321 asset_uuid: &str,
8322 edit_asset_request: &EditAssetRequest,
8323 ) -> Result<Asset, Error> {
8324 self.request_json(
8325 Method::PUT,
8326 &["assets", asset_uuid, "edit"],
8327 Some(edit_asset_request),
8328 )
8329 .await
8330 }
8331
8332 /// Registers an asset with the Blockstream Asset Registry.
8333 ///
8334 /// This method publishes an asset to the public registry, making it discoverable
8335 /// and verifiable by other users and applications. The asset must already exist
8336 /// in the AMP system before it can be registered.
8337 ///
8338 /// # Arguments
8339 ///
8340 /// * `asset_uuid` - The unique identifier of the asset to register
8341 ///
8342 /// # Returns
8343 ///
8344 /// Returns a `RegisterAssetResponse` containing:
8345 /// - `success`: Boolean indicating whether the registration was successful
8346 /// - `message`: Optional status message from the API
8347 /// - `asset_id`: The registered asset identifier (hex string)
8348 ///
8349 /// # Errors
8350 ///
8351 /// Returns an error if:
8352 /// - The asset does not exist or cannot be found (404)
8353 /// - Authentication fails or token is invalid (401)
8354 /// - The asset is already registered (returns success with appropriate message)
8355 /// - Network connectivity issues occur
8356 /// - The server returns an error status (5xx)
8357 /// - The response cannot be parsed
8358 ///
8359 /// # Examples
8360 ///
8361 /// ```no_run
8362 /// # use amp_rs::ApiClient;
8363 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
8364 /// let client = ApiClient::from_env().await?;
8365 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
8366 ///
8367 /// let response = client.register_asset(asset_uuid).await?;
8368 /// if response.success {
8369 /// println!("Asset registered successfully!");
8370 /// println!("Asset ID: {}", response.asset_id);
8371 /// if let Some(message) = response.message {
8372 /// println!("Message: {}", message);
8373 /// }
8374 /// }
8375 /// # Ok(())
8376 /// # }
8377 /// ```
8378 pub async fn register_asset(&self, asset_uuid: &str) -> Result<RegisterAssetResponse, Error> {
8379 // Make HTTP request directly to handle both success and error responses
8380 let token = self.get_token().await?;
8381 let mut url = self.base_url.clone();
8382 url.path_segments_mut()
8383 .unwrap()
8384 .extend(&["assets", asset_uuid, "register"]);
8385
8386 let response = self
8387 .client
8388 .request(Method::GET, url)
8389 .header(AUTHORIZATION, format!("token {token}"))
8390 .timeout(std::time::Duration::from_secs(60))
8391 .send()
8392 .await
8393 .map_err(|e| Error::RequestFailed(format!("HTTP request failed: {e}")))?;
8394
8395 let status = response.status();
8396 let response_text = response.text().await.map_err(|e| {
8397 Error::ResponseParsingFailed(format!("Failed to read response body: {e}"))
8398 })?;
8399
8400 // Handle HTTP 200 - success case
8401 if status == reqwest::StatusCode::OK {
8402 // Try to parse as Asset (full registration response)
8403 if let Ok(asset) = serde_json::from_str::<Asset>(&response_text) {
8404 return Ok(RegisterAssetResponse {
8405 success: true,
8406 message: Some("Asset registered successfully".to_string()),
8407 asset_data: Some(asset),
8408 });
8409 }
8410
8411 // If parsing as Asset fails, return success with raw message
8412 return Ok(RegisterAssetResponse {
8413 success: true,
8414 message: Some(response_text),
8415 asset_data: None,
8416 });
8417 }
8418
8419 // Handle error responses
8420 // Try to parse error response as JSON
8421 if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&response_text) {
8422 // Check for "already registered" error
8423 if let Some(error_msg) = error_json.get("Error").and_then(|e| e.as_str()) {
8424 let error_msg_lower = error_msg.to_lowercase();
8425 if error_msg_lower.contains("already registered") {
8426 return Ok(RegisterAssetResponse {
8427 success: true,
8428 message: Some("Asset is already registered".to_string()),
8429 asset_data: None,
8430 });
8431 }
8432
8433 // Other errors - return as error
8434 return Err(Error::RequestFailed(format!(
8435 "Request to [\"assets\", \"{asset_uuid}\", \"register\"] failed with status {status}: {error_msg}"
8436 )));
8437 }
8438 }
8439
8440 // Fallback error for non-JSON or unexpected responses
8441 Err(Error::RequestFailed(format!(
8442 "Request to [\"assets\", \"{asset_uuid}\", \"register\"] failed with status {status}: {response_text}"
8443 )))
8444 }
8445
8446 /// # Errors
8447 /// Returns an error if:
8448 /// - The asset does not exist or cannot be found
8449 /// - Authentication fails or token is invalid
8450 /// - Network connectivity issues occur
8451 /// - The server returns an error status
8452 pub async fn delete_asset(&self, asset_uuid: &str) -> Result<(), Error> {
8453 self.request_empty(
8454 Method::DELETE,
8455 &["assets", asset_uuid, "delete"],
8456 None::<&()>,
8457 )
8458 .await
8459 }
8460
8461 /// # Errors
8462 /// Returns an error if:
8463 /// - The transaction ID is invalid or not found
8464 /// - Authentication fails or token is invalid
8465 /// - Network connectivity issues occur
8466 /// - The server returns an error status
8467 /// - The response cannot be parsed
8468 pub async fn get_broadcast_status(&self, txid: &str) -> Result<BroadcastResponse, Error> {
8469 self.request_json(Method::GET, &["tx", "broadcast", txid], None::<&()>)
8470 .await
8471 }
8472
8473 /// # Errors
8474 /// Returns an error if:
8475 /// - The transaction hex is invalid or malformed
8476 /// - The transaction is rejected by the network
8477 /// - Authentication fails or token is invalid
8478 /// - Network connectivity issues occur
8479 /// - The server returns an error status
8480 /// - The response cannot be parsed
8481 pub async fn broadcast_transaction(&self, tx_hex: &str) -> Result<BroadcastResponse, Error> {
8482 self.request_json(Method::POST, &["tx", "broadcast"], Some(tx_hex))
8483 .await
8484 }
8485
8486 /// # Errors
8487 /// Returns an error if:
8488 /// - The asset UUID is invalid or not found
8489 /// - The user lacks authorization to register the asset
8490 /// - The asset is already registered
8491 /// - Authentication fails or token is invalid
8492 /// - Network connectivity issues occur
8493 /// - The server returns an error status
8494 /// - The response cannot be parsed
8495 pub async fn register_asset_authorized(&self, asset_uuid: &str) -> Result<Asset, Error> {
8496 self.request_json(
8497 Method::GET,
8498 &["assets", asset_uuid, "register-authorized"],
8499 None::<&()>,
8500 )
8501 .await
8502 }
8503
8504 /// # Errors
8505 /// Returns an error if:
8506 /// - The asset UUID is invalid or not found
8507 /// - The asset is already locked
8508 /// - The user lacks permission to lock the asset
8509 /// - Authentication fails or token is invalid
8510 /// - Network connectivity issues occur
8511 /// - The server returns an error status
8512 /// - The response cannot be parsed
8513 pub async fn lock_asset(&self, asset_uuid: &str) -> Result<Asset, Error> {
8514 self.request_json(Method::PUT, &["assets", asset_uuid, "lock"], None::<&()>)
8515 .await
8516 }
8517
8518 /// # Errors
8519 /// Returns an error if:
8520 /// - The asset UUID is invalid or not found
8521 /// - The asset is not currently locked
8522 /// - The user lacks permission to unlock the asset
8523 /// - Authentication fails or token is invalid
8524 /// - Network connectivity issues occur
8525 /// - The server returns an error status
8526 /// - The response cannot be parsed
8527 pub async fn unlock_asset(&self, asset_uuid: &str) -> Result<Asset, Error> {
8528 self.request_json(Method::PUT, &["assets", asset_uuid, "unlock"], None::<&()>)
8529 .await
8530 }
8531
8532 /// # Errors
8533 /// Returns an error if:
8534 /// - The asset UUID is invalid or not found
8535 /// - The activity parameters are invalid
8536 /// - Authentication fails or token is invalid
8537 /// - Network connectivity issues occur
8538 /// - The server returns an error status
8539 /// - The response cannot be parsed
8540 pub async fn get_asset_activities(
8541 &self,
8542 asset_uuid: &str,
8543 params: &AssetActivityParams,
8544 ) -> Result<Vec<Activity>, Error> {
8545 self.request_json(
8546 Method::GET,
8547 &["assets", asset_uuid, "activities"],
8548 Some(params),
8549 )
8550 .await
8551 }
8552
8553 /// # Errors
8554 /// Returns an error if:
8555 /// - The asset UUID is invalid or not found
8556 /// - The specified height is invalid or out of range
8557 /// - Authentication fails or token is invalid
8558 /// - Network connectivity issues occur
8559 /// - The server returns an error status
8560 /// - The response cannot be parsed
8561 pub async fn get_asset_ownerships(
8562 &self,
8563 asset_uuid: &str,
8564 height: Option<i64>,
8565 ) -> Result<Vec<Ownership>, Error> {
8566 let mut path = vec!["assets", asset_uuid, "ownerships"];
8567 let height_str;
8568 if let Some(h) = height {
8569 height_str = h.to_string();
8570 path.push(&height_str);
8571 }
8572 self.request_json(Method::GET, &path, None::<&()>).await
8573 }
8574
8575 /// # Errors
8576 /// Returns an error if:
8577 /// - The asset UUID is invalid or not found
8578 /// - Authentication fails or token is invalid
8579 /// - Network connectivity issues occur
8580 /// - The server returns an error status
8581 /// - The response cannot be parsed
8582 pub async fn get_asset_balance(&self, asset_uuid: &str) -> Result<Balance, Error> {
8583 self.request_json(Method::GET, &["assets", asset_uuid, "balance"], None::<&()>)
8584 .await
8585 }
8586
8587 /// # Errors
8588 /// Returns an error if:
8589 /// - The asset UUID is invalid or not found
8590 /// - Authentication fails or token is invalid
8591 /// - Network connectivity issues occur
8592 /// - The server returns an error status
8593 /// - The response cannot be parsed
8594 pub async fn get_asset_summary(&self, asset_uuid: &str) -> Result<AssetSummary, Error> {
8595 self.request_json(Method::GET, &["assets", asset_uuid, "summary"], None::<&()>)
8596 .await
8597 }
8598
8599 /// # Errors
8600 /// Returns an error if:
8601 /// - The asset UUID is invalid or not found
8602 /// - Authentication fails or token is invalid
8603 /// - Network connectivity issues occur
8604 /// - The server returns an error status
8605 /// - The response cannot be parsed
8606 pub async fn get_asset_utxos(&self, asset_uuid: &str) -> Result<Vec<Utxo>, Error> {
8607 self.request_json(Method::GET, &["assets", asset_uuid, "utxos"], None::<&()>)
8608 .await
8609 }
8610
8611 /// Gets the memo for a specific asset.
8612 ///
8613 /// # Arguments
8614 /// * `asset_uuid` - The UUID of the asset to retrieve the memo for
8615 ///
8616 /// # Returns
8617 /// The memo string associated with the asset
8618 ///
8619 /// # Errors
8620 /// Returns an error if:
8621 /// - Authentication fails
8622 /// - The HTTP request fails
8623 /// - The server returns an error status
8624 /// - The asset does not exist
8625 /// - The response cannot be parsed
8626 pub async fn get_asset_memo(&self, asset_uuid: &str) -> Result<String, Error> {
8627 self.request_json(Method::GET, &["assets", asset_uuid, "memo"], None::<&()>)
8628 .await
8629 }
8630
8631 /// Sets a memo for the specified asset.
8632 ///
8633 /// # Arguments
8634 /// * `asset_uuid` - The UUID of the asset to set the memo for
8635 /// * `memo` - The memo string to associate with the asset
8636 ///
8637 /// # Returns
8638 /// Returns `Ok(())` on success.
8639 ///
8640 /// # Errors
8641 /// Returns an error if:
8642 /// - Authentication fails
8643 /// - The HTTP request fails
8644 /// - The server returns an error status
8645 /// - The asset does not exist
8646 /// - The memo cannot be set due to validation errors
8647 ///
8648 /// # Example
8649 /// ```rust
8650 /// # use amp_rs::ApiClient;
8651 /// # async fn example(client: &ApiClient) -> Result<(), Box<dyn std::error::Error>> {
8652 /// client.set_asset_memo("asset-uuid-123", "This is a memo for the asset").await?;
8653 /// # Ok(())
8654 /// # }
8655 /// ```
8656 pub async fn set_asset_memo(&self, asset_uuid: &str, memo: &str) -> Result<(), Error> {
8657 let token = self.get_token().await?;
8658 let mut url = self.base_url.clone();
8659 url.path_segments_mut()
8660 .unwrap()
8661 .extend(&["assets", asset_uuid, "memo", "set"]);
8662
8663 let response = self
8664 .client
8665 .request(Method::POST, url)
8666 .header(AUTHORIZATION, format!("token {token}"))
8667 .header("content-type", "application/json")
8668 .body(format!("\"{}\"", memo.replace('"', "\\\"")))
8669 .send()
8670 .await?;
8671
8672 if !response.status().is_success() {
8673 let status = response.status();
8674 let error_text = response
8675 .text()
8676 .await
8677 .unwrap_or_else(|_| "Unknown error".to_string());
8678 return Err(Error::RequestFailed(format!(
8679 "Request to [\"assets\", \"{asset_uuid}\", \"memo\", \"set\"] failed with status {status}: {error_text}"
8680 )));
8681 }
8682
8683 Ok(())
8684 }
8685
8686 /// Blacklists specific UTXOs for an asset to prevent them from being used in transactions.
8687 ///
8688 /// This method adds the specified UTXOs to the asset's blacklist, preventing them from being
8689 /// used in future transactions. This is typically used for security purposes when UTXOs are
8690 /// suspected to be compromised or need to be temporarily disabled.
8691 ///
8692 /// # Arguments
8693 /// * `asset_uuid` - The UUID of the asset to blacklist UTXOs for
8694 /// * `utxos` - A slice of `Outpoint` structs representing the UTXOs to blacklist
8695 ///
8696 /// # Returns
8697 /// Returns a vector of `Utxo` structs representing the blacklisted UTXOs with their updated status.
8698 ///
8699 /// # Errors
8700 /// Returns an error if:
8701 /// - Authentication fails or insufficient permissions
8702 /// - The asset UUID is invalid or does not exist
8703 /// - One or more UTXOs are invalid or already blacklisted
8704 /// - The HTTP request fails
8705 /// - The server returns an error status
8706 /// - The response cannot be parsed
8707 ///
8708 /// # Examples
8709 /// ```no_run
8710 /// # use amp_rs::{ApiClient, model::Outpoint};
8711 /// # #[tokio::main]
8712 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8713 /// let client = ApiClient::new().await?;
8714 ///
8715 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
8716 /// let utxos = vec![
8717 /// Outpoint {
8718 /// txid: "abc123...".to_string(),
8719 /// vout: 0,
8720 /// },
8721 /// Outpoint {
8722 /// txid: "def456...".to_string(),
8723 /// vout: 1,
8724 /// },
8725 /// ];
8726 ///
8727 /// let blacklisted_utxos = client.blacklist_asset_utxos(asset_uuid, &utxos).await?;
8728 /// println!("Blacklisted {} UTXOs", blacklisted_utxos.len());
8729 /// # Ok(())
8730 /// # }
8731 /// ```
8732 ///
8733 /// # Related Methods
8734 /// - [`whitelist_asset_utxos`](Self::whitelist_asset_utxos) - Remove UTXOs from blacklist
8735 /// - [`get_asset`](Self::get_asset) - Get asset information including UTXO status
8736 pub async fn blacklist_asset_utxos(
8737 &self,
8738 asset_uuid: &str,
8739 utxos: &[Outpoint],
8740 ) -> Result<Vec<Utxo>, Error> {
8741 self.request_json(
8742 Method::POST,
8743 &["assets", asset_uuid, "utxos", "blacklist"],
8744 Some(utxos),
8745 )
8746 .await
8747 }
8748
8749 /// Removes UTXOs from the asset's blacklist, allowing them to be used in transactions again.
8750 ///
8751 /// This method removes the specified UTXOs from the asset's blacklist, restoring their ability
8752 /// to be used in transactions. This is the reverse operation of blacklisting UTXOs.
8753 ///
8754 /// # Arguments
8755 /// * `asset_uuid` - The UUID of the asset to whitelist UTXOs for
8756 /// * `utxos` - A slice of `Outpoint` structs representing the UTXOs to remove from blacklist
8757 ///
8758 /// # Returns
8759 /// Returns a vector of `Utxo` structs representing the whitelisted UTXOs with their updated status.
8760 ///
8761 /// # Errors
8762 /// Returns an error if:
8763 /// - Authentication fails or insufficient permissions
8764 /// - The asset UUID is invalid or does not exist
8765 /// - One or more UTXOs are invalid or not currently blacklisted
8766 /// - The HTTP request fails
8767 /// - The server returns an error status
8768 /// - The response cannot be parsed
8769 ///
8770 /// # Examples
8771 /// ```no_run
8772 /// # use amp_rs::{ApiClient, model::Outpoint};
8773 /// # #[tokio::main]
8774 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8775 /// let client = ApiClient::new().await?;
8776 ///
8777 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
8778 /// let utxos = vec![
8779 /// Outpoint {
8780 /// txid: "abc123...".to_string(),
8781 /// vout: 0,
8782 /// },
8783 /// ];
8784 ///
8785 /// let whitelisted_utxos = client.whitelist_asset_utxos(asset_uuid, &utxos).await?;
8786 /// println!("Whitelisted {} UTXOs", whitelisted_utxos.len());
8787 /// # Ok(())
8788 /// # }
8789 /// ```
8790 ///
8791 /// # Related Methods
8792 /// - [`blacklist_asset_utxos`](Self::blacklist_asset_utxos) - Add UTXOs to blacklist
8793 /// - [`get_asset`](Self::get_asset) - Get asset information including UTXO status
8794 pub async fn whitelist_asset_utxos(
8795 &self,
8796 asset_uuid: &str,
8797 utxos: &[Outpoint],
8798 ) -> Result<Vec<Utxo>, Error> {
8799 self.request_json(
8800 Method::POST,
8801 &["assets", asset_uuid, "utxos", "whitelist"],
8802 Some(utxos),
8803 )
8804 .await
8805 }
8806
8807 /// Gets the treasury addresses for a specific asset
8808 ///
8809 /// # Arguments
8810 /// * `asset_uuid` - The UUID of the asset to get treasury addresses for
8811 ///
8812 /// # Returns
8813 /// A vector of treasury addresses as strings
8814 ///
8815 /// # Errors
8816 /// Returns an error if:
8817 /// - The asset does not exist
8818 /// - The request fails
8819 /// - The response cannot be parsed
8820 pub async fn get_asset_treasury_addresses(
8821 &self,
8822 asset_uuid: &str,
8823 ) -> Result<Vec<String>, Error> {
8824 self.request_json(
8825 Method::GET,
8826 &["assets", asset_uuid, "treasury-addresses"],
8827 None::<&()>,
8828 )
8829 .await
8830 }
8831
8832 /// Adds treasury addresses to a specific asset
8833 ///
8834 /// # Arguments
8835 /// * `asset_uuid` - The UUID of the asset to add treasury addresses to
8836 /// * `addresses` - A slice of address strings to add as treasury addresses
8837 ///
8838 /// # Returns
8839 /// Returns `Ok(())` on success
8840 ///
8841 /// # Errors
8842 /// Returns an error if:
8843 /// - The asset does not exist
8844 /// - The addresses are invalid
8845 /// - The request fails
8846 /// - Insufficient permissions
8847 pub async fn add_asset_treasury_addresses(
8848 &self,
8849 asset_uuid: &str,
8850 addresses: &[String],
8851 ) -> Result<(), Error> {
8852 self.request_empty(
8853 Method::POST,
8854 &["assets", asset_uuid, "treasury-addresses", "add"],
8855 Some(addresses),
8856 )
8857 .await
8858 }
8859
8860 /// Removes treasury addresses from a specific asset.
8861 ///
8862 /// This method removes the specified addresses from the asset's treasury address list.
8863 /// Treasury addresses are special addresses that can be used for asset management operations
8864 /// such as reissuance and burning.
8865 ///
8866 /// # Arguments
8867 /// * `asset_uuid` - The UUID of the asset to remove treasury addresses from
8868 /// * `addresses` - A slice of address strings to remove from the treasury addresses
8869 ///
8870 /// # Returns
8871 /// Returns `Ok(())` on successful removal.
8872 ///
8873 /// # Errors
8874 /// Returns an error if:
8875 /// - Authentication fails or insufficient permissions
8876 /// - The asset UUID is invalid or does not exist
8877 /// - One or more addresses are invalid or not currently treasury addresses
8878 /// - The HTTP request fails
8879 /// - The server returns an error status
8880 /// - Attempting to remove the last treasury address (if not allowed)
8881 ///
8882 /// # Examples
8883 /// ```no_run
8884 /// # use amp_rs::ApiClient;
8885 /// # #[tokio::main]
8886 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8887 /// let client = ApiClient::new().await?;
8888 ///
8889 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
8890 /// let addresses = vec![
8891 /// "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
8892 /// "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(),
8893 /// ];
8894 ///
8895 /// client.delete_asset_treasury_addresses(asset_uuid, &addresses).await?;
8896 /// println!("Removed {} treasury addresses", addresses.len());
8897 /// # Ok(())
8898 /// # }
8899 /// ```
8900 ///
8901 /// # Related Methods
8902 /// - [`add_asset_treasury_addresses`](Self::add_asset_treasury_addresses) - Add treasury addresses
8903 /// - [`get_asset_treasury_addresses`](Self::get_asset_treasury_addresses) - Get current treasury addresses
8904 /// - [`reissue_asset`](Self::reissue_asset) - Reissue assets using treasury addresses
8905 pub async fn delete_asset_treasury_addresses(
8906 &self,
8907 asset_uuid: &str,
8908 addresses: &[String],
8909 ) -> Result<(), Error> {
8910 self.request_empty(
8911 Method::DELETE,
8912 &["assets", asset_uuid, "treasury-addresses", "delete"],
8913 Some(addresses),
8914 )
8915 .await
8916 }
8917
8918 /// Gets a list of all registered users.
8919 ///
8920 /// # Errors
8921 /// Returns an error if:
8922 /// - Authentication fails
8923 /// - The HTTP request fails
8924 /// - The server returns an error status
8925 /// - The response cannot be parsed
8926 ///
8927 /// # Examples
8928 /// ```no_run
8929 /// # use amp_rs::ApiClient;
8930 /// # #[tokio::main]
8931 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8932 /// let client = ApiClient::new().await?;
8933 ///
8934 /// let users = client.get_registered_users().await?;
8935 /// for user in users {
8936 /// println!("User: {} (ID: {})", user.name, user.id);
8937 /// }
8938 /// # Ok(())
8939 /// # }
8940 /// ```
8941 pub async fn get_registered_users(
8942 &self,
8943 ) -> Result<Vec<crate::model::RegisteredUserResponse>, Error> {
8944 self.request_json(Method::GET, &["registered_users"], None::<&()>)
8945 .await
8946 }
8947
8948 /// Gets a specific registered user by ID.
8949 ///
8950 /// # Arguments
8951 /// * `user_id` - The ID of the registered user to retrieve
8952 ///
8953 /// # Errors
8954 /// Returns an error if:
8955 /// - Authentication fails
8956 /// - The HTTP request fails
8957 /// - The user ID does not exist
8958 /// - The response cannot be parsed
8959 ///
8960 /// # Examples
8961 /// ```no_run
8962 /// # use amp_rs::ApiClient;
8963 /// # #[tokio::main]
8964 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8965 /// let client = ApiClient::new().await?;
8966 ///
8967 /// let user = client.get_registered_user(1).await?;
8968 /// println!("User: {} (ID: {})", user.name, user.id);
8969 /// # Ok(())
8970 /// # }
8971 /// ```
8972 pub async fn get_registered_user(
8973 &self,
8974 user_id: i64,
8975 ) -> Result<crate::model::RegisteredUserResponse, Error> {
8976 self.request_json(
8977 Method::GET,
8978 &["registered_users", &user_id.to_string()],
8979 None::<&()>,
8980 )
8981 .await
8982 }
8983
8984 /// Creates a new registered user in the AMP system.
8985 ///
8986 /// This method creates a new registered user with the provided information. Registered users
8987 /// can be associated with GAIDs, assigned to categories, and receive asset assignments.
8988 ///
8989 /// # Arguments
8990 /// * `new_user` - A `RegisteredUserAdd` struct containing the user information to create
8991 ///
8992 /// # Returns
8993 /// Returns a `RegisteredUserResponse` containing the created user's information including
8994 /// the assigned user ID.
8995 ///
8996 /// # Errors
8997 /// Returns an error if:
8998 /// - Authentication fails or insufficient permissions
8999 /// - The user data is invalid (e.g., missing required fields, invalid email format)
9000 /// - A user with the same identifier already exists
9001 /// - The HTTP request fails
9002 /// - The server returns an error status
9003 /// - The response cannot be parsed
9004 ///
9005 /// # Examples
9006 /// ```no_run
9007 /// # use amp_rs::{ApiClient, model::RegisteredUserAdd};
9008 /// # #[tokio::main]
9009 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9010 /// let client = ApiClient::new().await?;
9011 ///
9012 /// let new_user = RegisteredUserAdd {
9013 /// name: "John Doe".to_string(),
9014 /// gaid: Some("GAbYScu6jkWUND2jo3L4KJxyvo55d".to_string()),
9015 /// is_company: false,
9016 /// };
9017 ///
9018 /// let created_user = client.add_registered_user(&new_user).await?;
9019 /// println!("Created user: {} with ID {}", created_user.name, created_user.id);
9020 /// # Ok(())
9021 /// # }
9022 /// ```
9023 ///
9024 /// # Related Methods
9025 /// - [`get_registered_users`](Self::get_registered_users) - List all registered users
9026 /// - [`edit_registered_user`](Self::edit_registered_user) - Update user information
9027 /// - [`delete_registered_user`](Self::delete_registered_user) - Remove a user
9028 pub async fn add_registered_user(
9029 &self,
9030 new_user: &crate::model::RegisteredUserAdd,
9031 ) -> Result<crate::model::RegisteredUserResponse, Error> {
9032 self.request_json(Method::POST, &["registered_users", "add"], Some(new_user))
9033 .await
9034 }
9035
9036 /// Removes a registered user from the AMP system.
9037 ///
9038 /// This method permanently deletes a registered user and all associated data. This operation
9039 /// cannot be undone. Any GAIDs associated with the user will be disassociated, and any
9040 /// pending assignments may be affected.
9041 ///
9042 /// # Arguments
9043 /// * `user_id` - The ID of the registered user to delete
9044 ///
9045 /// # Returns
9046 /// Returns `Ok(())` on successful deletion.
9047 ///
9048 /// # Errors
9049 /// Returns an error if:
9050 /// - Authentication fails or insufficient permissions
9051 /// - The user ID is invalid or does not exist
9052 /// - The user has active assignments that prevent deletion
9053 /// - The HTTP request fails
9054 /// - The server returns an error status
9055 ///
9056 /// # Examples
9057 /// ```no_run
9058 /// # use amp_rs::ApiClient;
9059 /// # #[tokio::main]
9060 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9061 /// let client = ApiClient::new().await?;
9062 ///
9063 /// let user_id = 123;
9064 /// client.delete_registered_user(user_id).await?;
9065 /// println!("Successfully deleted user with ID {}", user_id);
9066 /// # Ok(())
9067 /// # }
9068 /// ```
9069 ///
9070 /// # Related Methods
9071 /// - [`get_registered_user`](Self::get_registered_user) - Get user information before deletion
9072 /// - [`add_registered_user`](Self::add_registered_user) - Create a new user
9073 /// - [`get_registered_user_summary`](Self::get_registered_user_summary) - Check user's assignments
9074 pub async fn delete_registered_user(&self, user_id: i64) -> Result<(), Error> {
9075 self.request_empty(
9076 Method::DELETE,
9077 &["registered_users", &user_id.to_string(), "delete"],
9078 None::<&()>,
9079 )
9080 .await
9081 }
9082
9083 /// Updates registered user information.
9084 ///
9085 /// This method allows you to modify the information of an existing registered user.
9086 /// Only the fields provided in the edit data will be updated; other fields remain unchanged.
9087 ///
9088 /// # Arguments
9089 /// * `registered_user_id` - The ID of the registered user to update
9090 /// * `edit_data` - A `RegisteredUserEdit` struct containing the fields to update
9091 ///
9092 /// # Returns
9093 /// Returns a `RegisteredUserResponse` containing the updated user information.
9094 ///
9095 /// # Errors
9096 /// Returns an error if:
9097 /// - Authentication fails or insufficient permissions
9098 /// - The user ID is invalid or does not exist
9099 /// - The edit data contains invalid values (e.g., invalid email format)
9100 /// - The HTTP request fails
9101 /// - The server returns an error status
9102 /// - The response cannot be parsed
9103 ///
9104 /// # Examples
9105 /// ```no_run
9106 /// # use amp_rs::{ApiClient, model::RegisteredUserEdit};
9107 /// # #[tokio::main]
9108 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9109 /// let client = ApiClient::new().await?;
9110 ///
9111 /// let user_id = 123;
9112 /// let edit_data = RegisteredUserEdit {
9113 /// name: Some("Jane Doe".to_string()),
9114 /// };
9115 ///
9116 /// let updated_user = client.edit_registered_user(user_id, &edit_data).await?;
9117 /// println!("Updated user: {}", updated_user.name);
9118 /// # Ok(())
9119 /// # }
9120 /// ```
9121 ///
9122 /// # Related Methods
9123 /// - [`get_registered_user`](Self::get_registered_user) - Get current user information
9124 /// - [`add_registered_user`](Self::add_registered_user) - Create a new user
9125 /// - [`delete_registered_user`](Self::delete_registered_user) - Remove a user
9126 pub async fn edit_registered_user(
9127 &self,
9128 registered_user_id: i64,
9129 edit_data: &crate::model::RegisteredUserEdit,
9130 ) -> Result<crate::model::RegisteredUserResponse, Error> {
9131 self.request_json(
9132 Method::PUT,
9133 &["registered_users", ®istered_user_id.to_string(), "edit"],
9134 Some(edit_data),
9135 )
9136 .await
9137 }
9138
9139 /// Gets comprehensive summary information for a registered user including assets and distributions.
9140 ///
9141 /// This method retrieves detailed summary information about a registered user, including
9142 /// their basic information, associated assets, assignment history, and distribution records.
9143 /// This provides a complete overview of the user's activity and holdings in the system.
9144 ///
9145 /// # Arguments
9146 /// * `registered_user_id` - The ID of the registered user to get summary for
9147 ///
9148 /// # Returns
9149 /// Returns a `RegisteredUserSummary` containing:
9150 /// - Basic user information (name, email, etc.)
9151 /// - List of associated GAIDs
9152 /// - Asset assignments and their status
9153 /// - Distribution history
9154 /// - Balance information
9155 /// - Activity timestamps
9156 ///
9157 /// # Errors
9158 /// Returns an error if:
9159 /// - Authentication fails or insufficient permissions
9160 /// - The user ID is invalid or does not exist
9161 /// - The HTTP request fails
9162 /// - The server returns an error status
9163 /// - The response cannot be parsed
9164 ///
9165 /// # Examples
9166 /// ```no_run
9167 /// # use amp_rs::ApiClient;
9168 /// # #[tokio::main]
9169 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9170 /// let client = ApiClient::new().await?;
9171 ///
9172 /// let user_id = 123;
9173 /// let summary = client.get_registered_user_summary(user_id).await?;
9174 ///
9175 /// println!("Asset UUID: {}", summary.asset_uuid);
9176 /// println!("Asset ID: {}", summary.asset_id);
9177 /// println!("Asset assignments: {}", summary.assignments.len());
9178 /// println!("Distributions received: {}", summary.distributions.len());
9179 /// # Ok(())
9180 /// # }
9181 /// ```
9182 ///
9183 /// # Related Methods
9184 /// - [`get_registered_user`](Self::get_registered_user) - Get basic user information
9185 /// - [`get_registered_user_gaids`](Self::get_registered_user_gaids) - Get only GAIDs
9186 /// - [`get_asset_assignments`](Self::get_asset_assignments) - Get assignments for specific asset
9187 pub async fn get_registered_user_summary(
9188 &self,
9189 registered_user_id: i64,
9190 ) -> Result<crate::model::RegisteredUserSummary, Error> {
9191 self.request_json(
9192 Method::GET,
9193 &[
9194 "registered_users",
9195 ®istered_user_id.to_string(),
9196 "summary",
9197 ],
9198 None::<&()>,
9199 )
9200 .await
9201 }
9202
9203 /// Gets all GAIDs (Green Address IDs) associated with a registered user.
9204 ///
9205 /// This method retrieves a list of all GAIDs that are currently associated with the specified
9206 /// registered user. GAIDs are unique identifiers that can be used to receive assets and
9207 /// track ownership.
9208 ///
9209 /// # Arguments
9210 /// * `registered_user_id` - The ID of the registered user to get GAIDs for
9211 ///
9212 /// # Returns
9213 /// Returns a vector of GAID strings associated with the user.
9214 ///
9215 /// # Errors
9216 /// Returns an error if:
9217 /// - Authentication fails or insufficient permissions
9218 /// - The user ID is invalid or does not exist
9219 /// - The HTTP request fails
9220 /// - The server returns an error status
9221 /// - The response cannot be parsed
9222 ///
9223 /// # Examples
9224 /// ```no_run
9225 /// # use amp_rs::ApiClient;
9226 /// # #[tokio::main]
9227 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9228 /// let client = ApiClient::new().await?;
9229 ///
9230 /// let user_id = 123;
9231 /// let gaids = client.get_registered_user_gaids(user_id).await?;
9232 ///
9233 /// println!("User {} has {} associated GAIDs:", user_id, gaids.len());
9234 /// for gaid in gaids {
9235 /// println!(" - {}", gaid);
9236 /// }
9237 /// # Ok(())
9238 /// # }
9239 /// ```
9240 ///
9241 /// # Related Methods
9242 /// - [`add_gaid_to_registered_user`](Self::add_gaid_to_registered_user) - Associate a GAID with user
9243 /// - [`set_default_gaid_for_registered_user`](Self::set_default_gaid_for_registered_user) - Set default GAID
9244 /// - [`get_gaid_registered_user`](Self::get_gaid_registered_user) - Find user by GAID
9245 /// - [`validate_gaid`](Self::validate_gaid) - Validate GAID format
9246 pub async fn get_registered_user_gaids(
9247 &self,
9248 registered_user_id: i64,
9249 ) -> Result<Vec<String>, Error> {
9250 self.request_json(
9251 Method::GET,
9252 &["registered_users", ®istered_user_id.to_string(), "gaids"],
9253 None::<&()>,
9254 )
9255 .await
9256 }
9257
9258 /// Associates a GAID with a registered user.
9259 ///
9260 /// # Arguments
9261 /// * `registered_user_id` - The ID of the registered user
9262 /// * `gaid` - The GAID to associate with the user
9263 ///
9264 /// # Errors
9265 ///
9266 /// Returns an error if:
9267 /// - Authentication fails
9268 /// - The HTTP request fails
9269 /// - The server returns an error status
9270 /// - The registered user ID is invalid
9271 /// - The GAID is invalid or already associated
9272 pub async fn add_gaid_to_registered_user(
9273 &self,
9274 registered_user_id: i64,
9275 gaid: &str,
9276 ) -> Result<(), Error> {
9277 let request = GaidRequest {
9278 gaid: gaid.to_string(),
9279 };
9280
9281 self.request_empty(
9282 Method::POST,
9283 &[
9284 "registered_users",
9285 ®istered_user_id.to_string(),
9286 "gaids",
9287 "add",
9288 ],
9289 Some(request),
9290 )
9291 .await
9292 }
9293
9294 /// Sets an existing GAID as the default for a registered user.
9295 ///
9296 /// This method allows you to designate a specific GAID as the primary/default
9297 /// GAID for a registered user. The GAID must already be associated with the user.
9298 ///
9299 /// # Arguments
9300 /// * `registered_user_id` - The ID of the registered user
9301 /// * `gaid` - The GAID to set as default
9302 ///
9303 /// # Returns
9304 /// Returns `Ok(())` if the operation is successful.
9305 ///
9306 /// # Errors
9307 /// Returns an error if:
9308 /// - Authentication fails
9309 /// - The HTTP request fails
9310 /// - The server returns an error status
9311 /// - The registered user ID is invalid
9312 /// - The GAID is not associated with the user
9313 pub async fn set_default_gaid_for_registered_user(
9314 &self,
9315 registered_user_id: i64,
9316 gaid: &str,
9317 ) -> Result<(), Error> {
9318 let request = GaidRequest {
9319 gaid: gaid.to_string(),
9320 };
9321
9322 self.request_empty(
9323 Method::POST,
9324 &[
9325 "registered_users",
9326 ®istered_user_id.to_string(),
9327 "gaids",
9328 "set-default",
9329 ],
9330 Some(request),
9331 )
9332 .await
9333 }
9334
9335 /// Retrieves the registered user associated with a GAID
9336 ///
9337 /// # Arguments
9338 /// * `gaid` - The GAID to look up
9339 ///
9340 /// # Returns
9341 /// Returns the registered user data if the GAID is associated with a user
9342 ///
9343 /// # Errors
9344 /// This function will return an error if:
9345 /// - The GAID has no associated user
9346 /// - The GAID is invalid
9347 /// - Network or authentication errors occur
9348 pub async fn get_gaid_registered_user(
9349 &self,
9350 gaid: &str,
9351 ) -> Result<crate::model::RegisteredUserResponse, Error> {
9352 self.request_json(
9353 Method::GET,
9354 &["gaids", gaid, "registered_user"],
9355 None::<&()>,
9356 )
9357 .await
9358 }
9359
9360 /// Gets the balance information for a specific GAID.
9361 ///
9362 /// This method retrieves all asset balances associated with the given GAID,
9363 /// including confirmed balances and any lost outputs.
9364 ///
9365 /// # Arguments
9366 /// * `gaid` - The GAID to query balance for
9367 ///
9368 /// # Returns
9369 /// Returns a `Balance` struct containing confirmed balances and lost outputs
9370 ///
9371 /// # Errors
9372 /// Returns an error if:
9373 /// - The GAID is invalid
9374 /// - Network or authentication errors occur
9375 /// - The response cannot be parsed
9376 ///
9377 /// # Examples
9378 /// ```no_run
9379 /// # use amp_rs::ApiClient;
9380 /// # #[tokio::main]
9381 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9382 /// let client = ApiClient::new().await?;
9383 ///
9384 /// let gaid = "GAbYScu6jkWUND2jo3L4KJxyvo55d";
9385 /// let balance = client.get_gaid_balance(gaid).await?;
9386 ///
9387 /// println!("GAID {} has {} balance entries", gaid, balance.len());
9388 /// for entry in balance {
9389 /// println!("Asset {}: {} units", entry.asset_id, entry.balance);
9390 /// }
9391 /// # Ok(())
9392 /// # }
9393 /// ```
9394 pub async fn get_gaid_balance(&self, gaid: &str) -> Result<Balance, Error> {
9395 self.request_json(Method::GET, &["gaids", gaid, "balance"], None::<&()>)
9396 .await
9397 }
9398
9399 /// Retrieves the specific asset balance for a GAID
9400 ///
9401 /// # Arguments
9402 /// * `gaid` - The GAID to query
9403 /// * `asset_uuid` - The UUID of the asset to query
9404 ///
9405 /// # Returns
9406 /// Returns the specific asset balance information
9407 ///
9408 /// # Errors
9409 /// Returns an error if:
9410 /// - The GAID is invalid
9411 /// - The asset UUID is invalid
9412 /// - Network or authentication errors occur
9413 /// - The response cannot be parsed
9414 pub async fn get_gaid_asset_balance(
9415 &self,
9416 gaid: &str,
9417 asset_uuid: &str,
9418 ) -> Result<Ownership, Error> {
9419 // Try to get the response as a GaidBalanceEntry first, then convert to Ownership
9420 let balance_entry: GaidBalanceEntry = self
9421 .request_json(
9422 Method::GET,
9423 &["gaids", gaid, "balance", asset_uuid],
9424 None::<&()>,
9425 )
9426 .await?;
9427
9428 // Convert GaidBalanceEntry to Ownership format
9429 Ok(Ownership {
9430 owner: gaid.to_string(),
9431 amount: balance_entry.balance,
9432 gaid: Some(gaid.to_string()),
9433 })
9434 }
9435
9436 /// Gets a list of all categories.
9437 ///
9438 /// # Returns
9439 /// Returns a vector of `CategoryResponse` objects
9440 ///
9441 /// # Errors
9442 /// Returns an error if:
9443 /// - Authentication fails
9444 /// - The HTTP request fails
9445 /// - The response cannot be parsed
9446 ///
9447 /// # Examples
9448 /// ```no_run
9449 /// # use amp_rs::ApiClient;
9450 /// # #[tokio::main]
9451 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9452 /// let client = ApiClient::new().await?;
9453 ///
9454 /// let categories = client.get_categories().await?;
9455 /// for category in categories {
9456 /// println!("Category: {} (ID: {})", category.name, category.id);
9457 /// if let Some(desc) = category.description {
9458 /// println!(" Description: {}", desc);
9459 /// }
9460 /// }
9461 /// # Ok(())
9462 /// # }
9463 /// ```
9464 pub async fn get_categories(&self) -> Result<Vec<CategoryResponse>, Error> {
9465 self.request_json(Method::GET, &["categories"], None::<&()>)
9466 .await
9467 }
9468
9469 /// Creates a new category for organizing users and assets.
9470 ///
9471 /// This method creates a new category that can be used to group registered users and assets
9472 /// for organizational purposes. Categories help manage permissions and provide logical
9473 /// groupings for assets and users.
9474 ///
9475 /// # Arguments
9476 /// * `new_category` - A `CategoryAdd` struct containing the category information to create
9477 ///
9478 /// # Returns
9479 /// Returns a `CategoryResponse` containing the created category information including
9480 /// the assigned category ID.
9481 ///
9482 /// # Errors
9483 /// Returns an error if:
9484 /// - Authentication fails or insufficient permissions
9485 /// - The category data is invalid (e.g., missing name, invalid characters)
9486 /// - A category with the same name already exists
9487 /// - The HTTP request fails
9488 /// - The server returns an error status
9489 /// - The response cannot be parsed
9490 ///
9491 /// # Examples
9492 /// ```no_run
9493 /// # use amp_rs::{ApiClient, model::CategoryAdd};
9494 /// # #[tokio::main]
9495 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9496 /// let client = ApiClient::new().await?;
9497 ///
9498 /// let new_category = CategoryAdd {
9499 /// name: "Premium Users".to_string(),
9500 /// description: Some("High-value users with special privileges".to_string()),
9501 /// };
9502 ///
9503 /// let created_category = client.add_category(&new_category).await?;
9504 /// println!("Created category: {} with ID {}", created_category.name, created_category.id);
9505 /// # Ok(())
9506 /// # }
9507 /// ```
9508 ///
9509 /// # Related Methods
9510 /// - [`get_categories`](Self::get_categories) - List all categories
9511 /// - [`edit_category`](Self::edit_category) - Update category information
9512 /// - [`delete_category`](Self::delete_category) - Remove a category
9513 /// - [`add_registered_user_to_category`](Self::add_registered_user_to_category) - Add users to category
9514 pub async fn add_category(
9515 &self,
9516 new_category: &CategoryAdd,
9517 ) -> Result<CategoryResponse, Error> {
9518 self.request_json(Method::POST, &["categories", "add"], Some(new_category))
9519 .await
9520 }
9521
9522 /// Gets a specific category by ID.
9523 ///
9524 /// This method retrieves detailed information about a specific category, including
9525 /// its name, description, and associated users and assets.
9526 ///
9527 /// # Arguments
9528 /// * `category_id` - The ID of the category to retrieve
9529 ///
9530 /// # Returns
9531 /// Returns a `CategoryResponse` containing the category information including:
9532 /// - Category ID, name, and description
9533 /// - List of associated registered users
9534 /// - List of associated assets
9535 /// - Creation and modification timestamps
9536 ///
9537 /// # Errors
9538 /// Returns an error if:
9539 /// - Authentication fails or insufficient permissions
9540 /// - The category ID is invalid or does not exist
9541 /// - The HTTP request fails
9542 /// - The server returns an error status
9543 /// - The response cannot be parsed
9544 ///
9545 /// # Examples
9546 /// ```no_run
9547 /// # use amp_rs::ApiClient;
9548 /// # #[tokio::main]
9549 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9550 /// let client = ApiClient::new().await?;
9551 ///
9552 /// let category_id = 1;
9553 /// let category = client.get_category(category_id).await?;
9554 ///
9555 /// println!("Category: {} (ID: {})", category.name, category.id);
9556 /// if let Some(desc) = category.description {
9557 /// println!("Description: {}", desc);
9558 /// }
9559 /// println!("Users: {}, Assets: {}", category.registered_users.len(), category.assets.len());
9560 /// # Ok(())
9561 /// # }
9562 /// ```
9563 ///
9564 /// # Related Methods
9565 /// - [`get_categories`](Self::get_categories) - List all categories
9566 /// - [`add_category`](Self::add_category) - Create a new category
9567 /// - [`edit_category`](Self::edit_category) - Update category information
9568 /// - [`delete_category`](Self::delete_category) - Remove a category
9569 pub async fn get_category(&self, category_id: i64) -> Result<CategoryResponse, Error> {
9570 self.request_json(
9571 Method::GET,
9572 &["categories", &category_id.to_string()],
9573 None::<&()>,
9574 )
9575 .await
9576 }
9577
9578 /// Updates category information.
9579 ///
9580 /// This method allows you to modify the information of an existing category.
9581 /// Only the fields provided in the edit data will be updated; other fields remain unchanged.
9582 ///
9583 /// # Arguments
9584 /// * `category_id` - The ID of the category to update
9585 /// * `edit_category` - A `CategoryEdit` struct containing the fields to update
9586 ///
9587 /// # Returns
9588 /// Returns a `CategoryResponse` containing the updated category information.
9589 ///
9590 /// # Errors
9591 /// Returns an error if:
9592 /// - Authentication fails or insufficient permissions
9593 /// - The category ID is invalid or does not exist
9594 /// - The edit data contains invalid values (e.g., empty name, invalid characters)
9595 /// - A category with the new name already exists (if name is being changed)
9596 /// - The HTTP request fails
9597 /// - The server returns an error status
9598 /// - The response cannot be parsed
9599 ///
9600 /// # Examples
9601 /// ```no_run
9602 /// # use amp_rs::{ApiClient, model::CategoryEdit};
9603 /// # #[tokio::main]
9604 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9605 /// let client = ApiClient::new().await?;
9606 ///
9607 /// let category_id = 1;
9608 /// let edit_data = CategoryEdit {
9609 /// name: Some("VIP Users".to_string()),
9610 /// description: Some("Very important users with premium access".to_string()),
9611 /// };
9612 ///
9613 /// let updated_category = client.edit_category(category_id, &edit_data).await?;
9614 /// println!("Updated category: {}", updated_category.name);
9615 /// # Ok(())
9616 /// # }
9617 /// ```
9618 ///
9619 /// # Related Methods
9620 /// - [`get_category`](Self::get_category) - Get current category information
9621 /// - [`add_category`](Self::add_category) - Create a new category
9622 /// - [`delete_category`](Self::delete_category) - Remove a category
9623 pub async fn edit_category(
9624 &self,
9625 category_id: i64,
9626 edit_category: &CategoryEdit,
9627 ) -> Result<CategoryResponse, Error> {
9628 self.request_json(
9629 Method::PUT,
9630 &["categories", &category_id.to_string(), "edit"],
9631 Some(edit_category),
9632 )
9633 .await
9634 }
9635
9636 /// Removes a category from the system.
9637 ///
9638 /// This method permanently deletes a category. All users and assets associated with the
9639 /// category will be disassociated, but the users and assets themselves are not deleted.
9640 /// This operation cannot be undone.
9641 ///
9642 /// # Arguments
9643 /// * `category_id` - The ID of the category to delete
9644 ///
9645 /// # Returns
9646 /// Returns `Ok(())` on successful deletion.
9647 ///
9648 /// # Errors
9649 /// Returns an error if:
9650 /// - Authentication fails or insufficient permissions
9651 /// - The category ID is invalid or does not exist
9652 /// - The category is still in use and cannot be deleted (depending on system configuration)
9653 /// - The HTTP request fails
9654 /// - The server returns an error status
9655 ///
9656 /// # Examples
9657 /// ```no_run
9658 /// # use amp_rs::ApiClient;
9659 /// # #[tokio::main]
9660 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9661 /// let client = ApiClient::new().await?;
9662 ///
9663 /// let category_id = 1;
9664 /// client.delete_category(category_id).await?;
9665 /// println!("Successfully deleted category with ID {}", category_id);
9666 /// # Ok(())
9667 /// # }
9668 /// ```
9669 ///
9670 /// # Related Methods
9671 /// - [`get_category`](Self::get_category) - Get category information before deletion
9672 /// - [`add_category`](Self::add_category) - Create a new category
9673 /// - [`remove_registered_user_from_category`](Self::remove_registered_user_from_category) - Remove users first
9674 /// - [`remove_asset_from_category`](Self::remove_asset_from_category) - Remove assets first
9675 pub async fn delete_category(&self, category_id: i64) -> Result<(), Error> {
9676 self.request_empty(
9677 Method::DELETE,
9678 &["categories", &category_id.to_string(), "delete"],
9679 None::<&()>,
9680 )
9681 .await
9682 }
9683
9684 /// Associates a registered user with a category.
9685 ///
9686 /// This method adds a registered user to a category, allowing for organized grouping
9687 /// of users. Users can belong to multiple categories, and categories can contain
9688 /// multiple users.
9689 ///
9690 /// # Arguments
9691 /// * `category_id` - The ID of the category to add the user to
9692 /// * `user_id` - The ID of the registered user to add to the category
9693 ///
9694 /// # Returns
9695 /// Returns a `CategoryResponse` containing the updated category information including
9696 /// the newly added user.
9697 ///
9698 /// # Errors
9699 /// Returns an error if:
9700 /// - Authentication fails or insufficient permissions
9701 /// - The category ID is invalid or does not exist
9702 /// - The user ID is invalid or does not exist
9703 /// - The user is already associated with the category
9704 /// - The HTTP request fails
9705 /// - The server returns an error status
9706 /// - The response cannot be parsed
9707 ///
9708 /// # Examples
9709 /// ```no_run
9710 /// # use amp_rs::ApiClient;
9711 /// # #[tokio::main]
9712 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9713 /// let client = ApiClient::new().await?;
9714 ///
9715 /// let category_id = 1;
9716 /// let user_id = 123;
9717 ///
9718 /// let updated_category = client.add_registered_user_to_category(category_id, user_id).await?;
9719 /// println!("Added user {} to category '{}'", user_id, updated_category.name);
9720 /// println!("Category now has {} users", updated_category.registered_users.len());
9721 /// # Ok(())
9722 /// # }
9723 /// ```
9724 ///
9725 /// # Related Methods
9726 /// - [`remove_registered_user_from_category`](Self::remove_registered_user_from_category) - Remove user from category
9727 /// - [`get_category`](Self::get_category) - Get category information including users
9728 /// - [`get_registered_user`](Self::get_registered_user) - Get user information
9729 pub async fn add_registered_user_to_category(
9730 &self,
9731 category_id: i64,
9732 user_id: i64,
9733 ) -> Result<CategoryResponse, Error> {
9734 self.request_json(
9735 Method::PUT,
9736 &[
9737 "categories",
9738 &category_id.to_string(),
9739 "registered_users",
9740 &user_id.to_string(),
9741 "add",
9742 ],
9743 None::<&()>,
9744 )
9745 .await
9746 }
9747
9748 /// Removes a registered user from a category.
9749 ///
9750 /// This method disassociates a registered user from a category. The user remains in the
9751 /// system but is no longer part of the specified category. This does not affect the user's
9752 /// association with other categories.
9753 ///
9754 /// # Arguments
9755 /// * `category_id` - The ID of the category to remove the user from
9756 /// * `user_id` - The ID of the registered user to remove from the category
9757 ///
9758 /// # Returns
9759 /// Returns a `CategoryResponse` containing the updated category information without
9760 /// the removed user.
9761 ///
9762 /// # Errors
9763 /// Returns an error if:
9764 /// - Authentication fails or insufficient permissions
9765 /// - The category ID is invalid or does not exist
9766 /// - The user ID is invalid or does not exist
9767 /// - The user is not currently associated with the category
9768 /// - The HTTP request fails
9769 /// - The server returns an error status
9770 /// - The response cannot be parsed
9771 ///
9772 /// # Examples
9773 /// ```no_run
9774 /// # use amp_rs::ApiClient;
9775 /// # #[tokio::main]
9776 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9777 /// let client = ApiClient::new().await?;
9778 ///
9779 /// let category_id = 1;
9780 /// let user_id = 123;
9781 ///
9782 /// let updated_category = client.remove_registered_user_from_category(category_id, user_id).await?;
9783 /// println!("Removed user {} from category '{}'", user_id, updated_category.name);
9784 /// println!("Category now has {} users", updated_category.registered_users.len());
9785 /// # Ok(())
9786 /// # }
9787 /// ```
9788 ///
9789 /// # Related Methods
9790 /// - [`add_registered_user_to_category`](Self::add_registered_user_to_category) - Add user to category
9791 /// - [`get_category`](Self::get_category) - Get category information including users
9792 /// - [`get_registered_user`](Self::get_registered_user) - Get user information
9793 pub async fn remove_registered_user_from_category(
9794 &self,
9795 category_id: i64,
9796 user_id: i64,
9797 ) -> Result<CategoryResponse, Error> {
9798 self.request_json(
9799 Method::PUT,
9800 &[
9801 "categories",
9802 &category_id.to_string(),
9803 "registered_users",
9804 &user_id.to_string(),
9805 "remove",
9806 ],
9807 None::<&()>,
9808 )
9809 .await
9810 }
9811
9812 /// Associates an asset with a category.
9813 ///
9814 /// This method adds an asset to a category, allowing for organized grouping of assets.
9815 /// Assets can belong to multiple categories, and categories can contain multiple assets.
9816 /// This helps with asset management and permission organization.
9817 ///
9818 /// # Arguments
9819 /// * `category_id` - The ID of the category to add the asset to
9820 /// * `asset_uuid` - The UUID of the asset to add to the category
9821 ///
9822 /// # Returns
9823 /// Returns a `CategoryResponse` containing the updated category information including
9824 /// the newly added asset.
9825 ///
9826 /// # Errors
9827 /// Returns an error if:
9828 /// - Authentication fails or insufficient permissions
9829 /// - The category ID is invalid or does not exist
9830 /// - The asset UUID is invalid or does not exist
9831 /// - The asset is already associated with the category
9832 /// - The HTTP request fails
9833 /// - The server returns an error status
9834 /// - The response cannot be parsed
9835 ///
9836 /// # Examples
9837 /// ```no_run
9838 /// # use amp_rs::ApiClient;
9839 /// # #[tokio::main]
9840 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9841 /// let client = ApiClient::new().await?;
9842 ///
9843 /// let category_id = 1;
9844 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
9845 ///
9846 /// let updated_category = client.add_asset_to_category(category_id, asset_uuid).await?;
9847 /// println!("Added asset {} to category '{}'", asset_uuid, updated_category.name);
9848 /// println!("Category now has {} assets", updated_category.assets.len());
9849 /// # Ok(())
9850 /// # }
9851 /// ```
9852 ///
9853 /// # Related Methods
9854 /// - [`remove_asset_from_category`](Self::remove_asset_from_category) - Remove asset from category
9855 /// - [`get_category`](Self::get_category) - Get category information including assets
9856 /// - [`get_asset`](Self::get_asset) - Get asset information
9857 pub async fn add_asset_to_category(
9858 &self,
9859 category_id: i64,
9860 asset_uuid: &str,
9861 ) -> Result<CategoryResponse, Error> {
9862 self.request_json(
9863 Method::PUT,
9864 &[
9865 "categories",
9866 &category_id.to_string(),
9867 "assets",
9868 asset_uuid,
9869 "add",
9870 ],
9871 None::<&()>,
9872 )
9873 .await
9874 }
9875
9876 /// Removes an asset from a category.
9877 ///
9878 /// This method disassociates an asset from a category. The asset remains in the system
9879 /// but is no longer part of the specified category. This does not affect the asset's
9880 /// association with other categories.
9881 ///
9882 /// # Arguments
9883 /// * `category_id` - The ID of the category to remove the asset from
9884 /// * `asset_uuid` - The UUID of the asset to remove from the category
9885 ///
9886 /// # Returns
9887 /// Returns a `CategoryResponse` containing the updated category information without
9888 /// the removed asset.
9889 ///
9890 /// # Errors
9891 /// Returns an error if:
9892 /// - Authentication fails or insufficient permissions
9893 /// - The category ID is invalid or does not exist
9894 /// - The asset UUID is invalid or does not exist
9895 /// - The asset is not currently associated with the category
9896 /// - The HTTP request fails
9897 /// - The server returns an error status
9898 /// - The response cannot be parsed
9899 ///
9900 /// # Examples
9901 /// ```no_run
9902 /// # use amp_rs::ApiClient;
9903 /// # #[tokio::main]
9904 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9905 /// let client = ApiClient::new().await?;
9906 ///
9907 /// let category_id = 1;
9908 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
9909 ///
9910 /// let updated_category = client.remove_asset_from_category(category_id, asset_uuid).await?;
9911 /// println!("Removed asset {} from category '{}'", asset_uuid, updated_category.name);
9912 /// println!("Category now has {} assets", updated_category.assets.len());
9913 /// # Ok(())
9914 /// # }
9915 /// ```
9916 ///
9917 /// # Related Methods
9918 /// - [`add_asset_to_category`](Self::add_asset_to_category) - Add asset to category
9919 /// - [`get_category`](Self::get_category) - Get category information including assets
9920 /// - [`get_asset`](Self::get_asset) - Get asset information
9921 pub async fn remove_asset_from_category(
9922 &self,
9923 category_id: i64,
9924 asset_uuid: &str,
9925 ) -> Result<CategoryResponse, Error> {
9926 self.request_json(
9927 Method::PUT,
9928 &[
9929 "categories",
9930 &category_id.to_string(),
9931 "assets",
9932 asset_uuid,
9933 "remove",
9934 ],
9935 None::<&()>,
9936 )
9937 .await
9938 }
9939
9940 /// Validates a GAID (Green Address ID).
9941 ///
9942 /// # Arguments
9943 /// * `gaid` - The GAID string to validate
9944 ///
9945 /// # Returns
9946 /// Returns a `ValidateGaidResponse` indicating whether the GAID is valid
9947 ///
9948 /// # Errors
9949 /// Returns an error if:
9950 /// - Authentication fails
9951 /// - The HTTP request fails
9952 /// - The response cannot be parsed
9953 ///
9954 /// # Examples
9955 /// ```no_run
9956 /// # use amp_rs::ApiClient;
9957 /// # #[tokio::main]
9958 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9959 /// let client = ApiClient::new().await?;
9960 ///
9961 /// let gaid = "GAbYScu6jkWUND2jo3L4KJxyvo55d";
9962 /// let validation = client.validate_gaid(gaid).await?;
9963 ///
9964 /// if validation.is_valid {
9965 /// println!("GAID {} is valid", gaid);
9966 /// } else {
9967 /// println!("GAID {} is invalid: {:?}", gaid, validation.error);
9968 /// }
9969 /// # Ok(())
9970 /// # }
9971 /// ```
9972 pub async fn validate_gaid(
9973 &self,
9974 gaid: &str,
9975 ) -> Result<crate::model::ValidateGaidResponse, Error> {
9976 self.request_json(Method::GET, &["gaids", gaid, "validate"], None::<&()>)
9977 .await
9978 }
9979
9980 /// Gets the address associated with a GAID.
9981 ///
9982 /// # Arguments
9983 /// * `gaid` - The GAID to get the address for
9984 ///
9985 /// # Returns
9986 /// Returns an `AddressGaidResponse` containing the address
9987 ///
9988 /// # Errors
9989 /// Returns an error if:
9990 /// - The GAID is invalid
9991 /// - Authentication fails
9992 /// - The HTTP request fails
9993 /// - The response cannot be parsed
9994 ///
9995 /// # Examples
9996 /// ```no_run
9997 /// # use amp_rs::ApiClient;
9998 /// # #[tokio::main]
9999 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10000 /// let client = ApiClient::new().await?;
10001 ///
10002 /// let gaid = "GAbYScu6jkWUND2jo3L4KJxyvo55d";
10003 /// let address_response = client.get_gaid_address(gaid).await?;
10004 ///
10005 /// println!("Address for GAID {}: {}", gaid, address_response.address);
10006 /// # Ok(())
10007 /// # }
10008 /// ```
10009 pub async fn get_gaid_address(
10010 &self,
10011 gaid: &str,
10012 ) -> Result<crate::model::AddressGaidResponse, Error> {
10013 self.request_json(Method::GET, &["gaids", gaid, "address"], None::<&()>)
10014 .await
10015 }
10016
10017 /// Gets a list of all managers.
10018 ///
10019 /// # Returns
10020 /// Returns a vector of `Manager` objects
10021 ///
10022 /// # Errors
10023 /// Returns an error if:
10024 /// - Authentication fails
10025 /// - The HTTP request fails
10026 /// - The response cannot be parsed
10027 ///
10028 /// # Examples
10029 /// ```no_run
10030 /// # use amp_rs::ApiClient;
10031 /// # #[tokio::main]
10032 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10033 /// let client = ApiClient::new().await?;
10034 ///
10035 /// let managers = client.get_managers().await?;
10036 /// for manager in managers {
10037 /// println!("Manager: {} (ID: {})", manager.username, manager.id);
10038 /// }
10039 /// # Ok(())
10040 /// # }
10041 /// ```
10042 pub async fn get_managers(&self) -> Result<Vec<crate::model::Manager>, Error> {
10043 self.request_json(Method::GET, &["managers"], None::<&()>)
10044 .await
10045 }
10046
10047 /// Creates a new manager.
10048 ///
10049 /// # Arguments
10050 /// * `new_manager` - The manager creation request containing username and password
10051 ///
10052 /// # Returns
10053 /// Returns the created `Manager` object
10054 ///
10055 /// # Errors
10056 /// Returns an error if:
10057 /// - Authentication fails
10058 /// - The HTTP request fails
10059 /// - The manager creation request is invalid
10060 /// - The response cannot be parsed
10061 ///
10062 /// # Examples
10063 /// ```no_run
10064 /// # use amp_rs::{ApiClient, model::ManagerCreate};
10065 /// # #[tokio::main]
10066 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10067 /// let client = ApiClient::new().await?;
10068 ///
10069 /// let new_manager = ManagerCreate {
10070 /// username: "new_manager".to_string(),
10071 /// password: "secure_password".to_string(),
10072 /// };
10073 ///
10074 /// let manager = client.create_manager(&new_manager).await?;
10075 /// println!("Created manager: {} (ID: {})", manager.username, manager.id);
10076 /// # Ok(())
10077 /// # }
10078 /// ```
10079 pub async fn create_manager(
10080 &self,
10081 new_manager: &crate::model::ManagerCreate,
10082 ) -> Result<crate::model::Manager, Error> {
10083 self.request_json(Method::POST, &["managers", "create"], Some(new_manager))
10084 .await
10085 }
10086
10087 /// Gets all assignments for a specific asset.
10088 ///
10089 /// # Arguments
10090 /// * `asset_uuid` - The UUID of the asset to get assignments for
10091 ///
10092 /// # Returns
10093 /// Returns a vector of `Assignment` objects
10094 ///
10095 /// # Errors
10096 /// Returns an error if:
10097 /// - Authentication fails
10098 /// - The HTTP request fails
10099 /// - The asset UUID is invalid
10100 /// - The response cannot be parsed
10101 ///
10102 /// # Examples
10103 /// ```no_run
10104 /// # use amp_rs::ApiClient;
10105 /// # #[tokio::main]
10106 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10107 /// let client = ApiClient::new().await?;
10108 ///
10109 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10110 /// let assignments = client.get_asset_assignments(asset_uuid).await?;
10111 ///
10112 /// for assignment in assignments {
10113 /// println!("Assignment ID: {}, Amount: {}", assignment.id, assignment.amount);
10114 /// }
10115 /// # Ok(())
10116 /// # }
10117 /// ```
10118 pub async fn get_asset_assignments(&self, asset_uuid: &str) -> Result<Vec<Assignment>, Error> {
10119 self.request_json(
10120 Method::GET,
10121 &["assets", asset_uuid, "assignments"],
10122 None::<&()>,
10123 )
10124 .await
10125 }
10126
10127 /// Creates multiple asset assignments in batch.
10128 ///
10129 /// This method creates multiple asset assignments for the specified asset. Each assignment
10130 /// allocates a specific amount of the asset to a registered user. The assignments are
10131 /// created individually due to API limitations, but this method handles the batch processing
10132 /// automatically.
10133 ///
10134 /// # Arguments
10135 /// * `asset_uuid` - The UUID of the asset to create assignments for
10136 /// * `requests` - A slice of `CreateAssetAssignmentRequest` structs containing assignment details
10137 ///
10138 /// # Returns
10139 /// Returns a vector of `Assignment` structs representing the created assignments with their
10140 /// assigned IDs and status information.
10141 ///
10142 /// # Errors
10143 /// Returns an error if:
10144 /// - Authentication fails or insufficient permissions
10145 /// - The asset UUID is invalid or does not exist
10146 /// - Any assignment request contains invalid data (e.g., invalid user ID, negative amount)
10147 /// - Insufficient asset balance for the total requested assignments
10148 /// - Any individual assignment creation fails
10149 /// - The HTTP request fails
10150 /// - The server returns an error status
10151 /// - The response cannot be parsed
10152 ///
10153 /// # Examples
10154 /// ```no_run
10155 /// # use amp_rs::{ApiClient, model::CreateAssetAssignmentRequest};
10156 /// # #[tokio::main]
10157 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10158 /// let client = ApiClient::new().await?;
10159 ///
10160 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10161 /// let requests = vec![
10162 /// CreateAssetAssignmentRequest {
10163 /// registered_user: 123,
10164 /// amount: 1000,
10165 /// vesting_timestamp: None,
10166 /// ready_for_distribution: false,
10167 /// },
10168 /// CreateAssetAssignmentRequest {
10169 /// registered_user: 456,
10170 /// amount: 500,
10171 /// vesting_timestamp: None,
10172 /// ready_for_distribution: true,
10173 /// },
10174 /// ];
10175 ///
10176 /// let assignments = client.create_asset_assignments(asset_uuid, &requests).await?;
10177 /// println!("Created {} assignments", assignments.len());
10178 /// for assignment in assignments {
10179 /// println!("Assignment {}: {} units to user {}",
10180 /// assignment.id, assignment.amount, assignment.registered_user);
10181 /// }
10182 /// # Ok(())
10183 /// # }
10184 /// ```
10185 ///
10186 /// # Related Methods
10187 /// - [`get_asset_assignments`](Self::get_asset_assignments) - List all assignments for an asset
10188 /// - [`delete_asset_assignment`](Self::delete_asset_assignment) - Remove an assignment
10189 /// - [`edit_asset_assignment`](Self::edit_asset_assignment) - Update assignment details
10190 /// - [`set_assignment_ready_for_distribution`](Self::set_assignment_ready_for_distribution) - Mark for distribution
10191 pub async fn create_asset_assignments(
10192 &self,
10193 asset_uuid: &str,
10194 requests: &[CreateAssetAssignmentRequest],
10195 ) -> Result<Vec<Assignment>, Error> {
10196 use crate::model::CreateAssetAssignmentRequestWrapper;
10197
10198 // The API only supports maximum length 1 per request, so we need to break
10199 // multiple assignments into separate CreateAssetAssignmentRequestWrapper instances
10200 let mut all_assignments = Vec::new();
10201
10202 for request in requests {
10203 let wrapper = CreateAssetAssignmentRequestWrapper {
10204 assignments: vec![request.clone()],
10205 };
10206
10207 let assignments: Vec<Assignment> = self
10208 .request_json(
10209 Method::POST,
10210 &["assets", asset_uuid, "assignments", "create"],
10211 Some(&wrapper),
10212 )
10213 .await?;
10214
10215 all_assignments.extend(assignments);
10216 }
10217
10218 Ok(all_assignments)
10219 }
10220
10221 /// Gets a specific asset assignment by asset UUID and assignment ID.
10222 ///
10223 /// This method sends a GET request to retrieve detailed information about a specific asset
10224 /// assignment. Asset assignments represent the allocation of assets to users or entities,
10225 /// including information such as the assigned amount, recipient details, and assignment status.
10226 ///
10227 /// # Arguments
10228 /// * `asset_uuid` - The UUID of the asset for which to retrieve the assignment
10229 /// * `assignment_id` - The ID of the specific assignment to retrieve
10230 ///
10231 /// # Returns
10232 /// Returns an `Assignment` struct containing the assignment details including:
10233 /// - Assignment ID and amount
10234 /// - Recipient information
10235 /// - Assignment status and metadata
10236 /// - Creation and modification timestamps
10237 ///
10238 /// # Errors
10239 /// Returns an error if:
10240 /// - Authentication fails
10241 /// - The HTTP request fails
10242 /// - The server returns an error status
10243 /// - The asset UUID is invalid or does not exist
10244 /// - The assignment ID is invalid or does not exist
10245 /// - The assignment is not accessible to the current user
10246 /// - The response cannot be parsed as a valid Assignment
10247 ///
10248 /// # Example
10249 /// ```no_run
10250 /// # use amp_rs::ApiClient;
10251 /// # #[tokio::main]
10252 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10253 /// let client = ApiClient::new().await?;
10254 ///
10255 /// // Retrieve assignment with ID "123" for asset "550e8400-e29b-41d4-a716-446655440000"
10256 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10257 /// let assignment_id = "123";
10258 ///
10259 /// let assignment = client.get_asset_assignment(asset_uuid, assignment_id).await?;
10260 ///
10261 /// println!("Assignment ID: {}", assignment.id);
10262 /// println!("Assigned amount: {}", assignment.amount);
10263 /// println!("Registered user: {}", assignment.registered_user);
10264 /// # Ok(())
10265 /// # }
10266 /// ```
10267 pub async fn get_asset_assignment(
10268 &self,
10269 asset_uuid: &str,
10270 assignment_id: &str,
10271 ) -> Result<Assignment, Error> {
10272 self.request_json(
10273 Method::GET,
10274 &["assets", asset_uuid, "assignments", assignment_id],
10275 None::<&()>,
10276 )
10277 .await
10278 }
10279
10280 /// Creates a distribution for an asset with the specified assignments.
10281 ///
10282 /// This method initiates the distribution creation process by sending assignment details
10283 /// to the AMP API. The API will return a distribution UUID and address mappings that
10284 /// can be used for subsequent transaction creation and confirmation steps.
10285 ///
10286 /// # Arguments
10287 /// * `asset_uuid` - The UUID of the asset to distribute
10288 /// * `assignments` - A vector of `AssetDistributionAssignment` structs containing user IDs, addresses, and amounts
10289 ///
10290 /// # Returns
10291 /// Returns a `DistributionResponse` containing:
10292 /// - `distribution_uuid` - Unique identifier for the created distribution
10293 /// - `map_address_amount` - Mapping of addresses to amounts to be distributed
10294 /// - `map_address_asset` - Mapping of addresses to asset IDs
10295 /// - `asset_id` - The asset ID for the distribution
10296 ///
10297 /// # Errors
10298 /// Returns an `AmpError` if:
10299 /// - Authentication fails or insufficient permissions
10300 /// - The asset UUID is invalid or does not exist
10301 /// - Assignment data is invalid (e.g., invalid user IDs, negative amounts, invalid addresses)
10302 /// - Insufficient asset balance for the requested distribution
10303 /// - The HTTP request fails
10304 /// - The server returns an error status
10305 /// - The response cannot be parsed
10306 ///
10307 /// # Examples
10308 /// ```no_run
10309 /// # use amp_rs::{ApiClient, model::AssetDistributionAssignment, AmpError};
10310 /// # #[tokio::main]
10311 /// # async fn main() -> Result<(), AmpError> {
10312 /// let client = ApiClient::new().await.map_err(AmpError::from)?;
10313 ///
10314 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10315 /// let assignments = vec![
10316 /// AssetDistributionAssignment {
10317 /// user_id: "user123".to_string(),
10318 /// address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
10319 /// amount: 100.0,
10320 /// },
10321 /// AssetDistributionAssignment {
10322 /// user_id: "user456".to_string(),
10323 /// address: "lq1qq3xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
10324 /// amount: 50.0,
10325 /// },
10326 /// ];
10327 ///
10328 /// let distribution_response = client.create_distribution(asset_uuid, assignments).await?;
10329 /// println!("Created distribution: {}", distribution_response.distribution_uuid);
10330 /// println!("Asset ID: {}", distribution_response.asset_id);
10331 /// # Ok(())
10332 /// # }
10333 /// ```
10334 ///
10335 /// # Related Methods
10336 /// - [`get_asset_assignments`](Self::get_asset_assignments) - List assignments for an asset
10337 /// - [`create_asset_assignments`](Self::create_asset_assignments) - Create new assignments
10338 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
10339 pub async fn create_distribution(
10340 &self,
10341 asset_uuid: &str,
10342 assignments: Vec<crate::model::AssetDistributionAssignment>,
10343 ) -> Result<crate::model::DistributionResponse, AmpError> {
10344 use crate::model::{CreateDistributionRequest, DistributionAssignmentRequest};
10345
10346 let create_span = tracing::debug_span!(
10347 "create_distribution",
10348 asset_uuid = %asset_uuid,
10349 assignment_count = assignments.len()
10350 );
10351 let _enter = create_span.enter();
10352
10353 tracing::debug!(
10354 "Creating distribution for asset {} with {} assignments",
10355 asset_uuid,
10356 assignments.len()
10357 );
10358
10359 // Validate inputs
10360 if asset_uuid.is_empty() {
10361 tracing::error!("Distribution creation failed: empty asset UUID");
10362 return Err(AmpError::validation("Asset UUID cannot be empty"));
10363 }
10364
10365 if assignments.is_empty() {
10366 tracing::error!("Distribution creation failed: empty assignments");
10367 return Err(AmpError::validation("Assignments cannot be empty"));
10368 }
10369
10370 // Convert AssetDistributionAssignment to DistributionAssignmentRequest
10371 // The API expects user_uuid field, but our input uses user_id
10372 tracing::trace!("Converting {} assignments to API format", assignments.len());
10373 let mut total_amount = 0.0;
10374 let api_assignments: Vec<DistributionAssignmentRequest> = assignments
10375 .into_iter()
10376 .enumerate()
10377 .map(
10378 #[allow(clippy::cognitive_complexity)]
10379 |(index, assignment)| {
10380 tracing::trace!(
10381 "Converting assignment {}: user_id={}, address={}, amount={}",
10382 index,
10383 assignment.user_id,
10384 assignment.address,
10385 assignment.amount
10386 );
10387
10388 // Validate assignment data
10389 if assignment.user_id.is_empty() {
10390 tracing::error!("Assignment {} has empty user_id", index);
10391 return Err(AmpError::validation(format!(
10392 "Assignment {index} has empty user_id"
10393 )));
10394 }
10395 if assignment.address.is_empty() {
10396 tracing::error!("Assignment {} has empty address", index);
10397 return Err(AmpError::validation(format!(
10398 "Assignment {index} has empty address"
10399 )));
10400 }
10401 if assignment.amount <= 0.0 {
10402 tracing::error!(
10403 "Assignment {} has non-positive amount: {}",
10404 index,
10405 assignment.amount
10406 );
10407 return Err(AmpError::validation(format!(
10408 "Assignment {} has non-positive amount: {}",
10409 index, assignment.amount
10410 )));
10411 }
10412
10413 total_amount += assignment.amount;
10414
10415 Ok(DistributionAssignmentRequest {
10416 user_uuid: assignment.user_id, // Map user_id to user_uuid for API
10417 amount: assignment.amount,
10418 address: assignment.address,
10419 })
10420 },
10421 )
10422 .collect::<Result<Vec<_>, AmpError>>()?;
10423
10424 tracing::debug!(
10425 "Converted {} assignments successfully, total amount: {}",
10426 api_assignments.len(),
10427 total_amount
10428 );
10429
10430 let request = CreateDistributionRequest {
10431 assignments: api_assignments,
10432 };
10433
10434 tracing::debug!("Sending distribution creation request to AMP API");
10435 let api_call_start = std::time::Instant::now();
10436
10437 // Make the API call
10438 let response: crate::model::DistributionResponse = self
10439 .request_json(
10440 Method::GET,
10441 &["assets", asset_uuid, "distributions", "create"],
10442 Some(&request),
10443 )
10444 .await
10445 .map_err(
10446 #[allow(clippy::cognitive_complexity)]
10447 |e| {
10448 let api_call_duration = api_call_start.elapsed();
10449 let error_msg =
10450 format!("Failed to create distribution after {api_call_duration:?}: {e}");
10451 tracing::error!("{}", error_msg);
10452
10453 // Check for specific API error patterns
10454 let error_str = e.to_string();
10455 if error_str.contains("404") || error_str.contains("not found") {
10456 tracing::error!(
10457 "Asset {} not found - verify asset UUID is correct",
10458 asset_uuid
10459 );
10460 } else if error_str.contains("400") || error_str.contains("bad request") {
10461 tracing::error!("Bad request - check assignment data format and values");
10462 } else if error_str.contains("401") || error_str.contains("unauthorized") {
10463 tracing::error!("Unauthorized - check API credentials and token validity");
10464 } else if error_str.contains("403") || error_str.contains("forbidden") {
10465 tracing::error!("Forbidden - check permissions for asset distribution");
10466 } else if error_str.contains("429") || error_str.contains("rate limit") {
10467 tracing::error!("Rate limited - wait before retrying");
10468 } else if error_str.contains("500") || error_str.contains("internal server") {
10469 tracing::error!(
10470 "Server error - this may be a temporary issue, retry may help"
10471 );
10472 }
10473
10474 AmpError::api(error_msg)
10475 },
10476 )?;
10477
10478 let api_call_duration = api_call_start.elapsed();
10479 tracing::info!(
10480 "Successfully created distribution: {} (took {:?})",
10481 response.distribution_uuid,
10482 api_call_duration
10483 );
10484
10485 // Validate response data
10486 if response.distribution_uuid.is_empty() {
10487 tracing::error!("API returned empty distribution UUID");
10488 return Err(AmpError::api("API returned empty distribution UUID"));
10489 }
10490
10491 if response.asset_id.is_empty() {
10492 tracing::error!("API returned empty asset ID");
10493 return Err(AmpError::api("API returned empty asset ID"));
10494 }
10495
10496 if response.map_address_amount.is_empty() {
10497 tracing::error!("API returned empty address mapping");
10498 return Err(AmpError::api("API returned empty address mapping"));
10499 }
10500
10501 tracing::debug!(
10502 "Distribution response validated - {} addresses mapped, asset_id: {}",
10503 response.map_address_amount.len(),
10504 response.asset_id
10505 );
10506
10507 Ok(response)
10508 }
10509
10510 /// Confirms a distribution with transaction and change data.
10511 ///
10512 /// This method submits the final confirmation for a distribution by providing
10513 /// the transaction details and any change UTXOs to the AMP API. This completes
10514 /// the distribution workflow after the transaction has been broadcast and confirmed
10515 /// on the blockchain.
10516 ///
10517 /// # Arguments
10518 /// * `asset_uuid` - The UUID of the asset being distributed
10519 /// * `distribution_uuid` - The UUID of the distribution to confirm (from `create_distribution` response)
10520 /// * `tx_data` - Transaction data containing details and txid from the blockchain
10521 /// * `change_data` - Vector of change UTXOs from the transaction
10522 ///
10523 /// # Errors
10524 /// Returns an error if:
10525 /// - Authentication fails
10526 /// - The asset UUID or distribution UUID is invalid
10527 /// - The transaction data is invalid or incomplete
10528 /// - The HTTP request fails
10529 /// - The server returns an error status
10530 /// - The response cannot be parsed
10531 ///
10532 /// # Examples
10533 /// ```no_run
10534 /// # use amp_rs::{ApiClient, model::{AmpTxData, Unspent}, AmpError};
10535 /// # #[tokio::main]
10536 /// # async fn main() -> Result<(), AmpError> {
10537 /// # let client = ApiClient::new().await?;
10538 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10539 /// let distribution_uuid = "dist-550e8400-e29b-41d4-a716-446655440000";
10540 ///
10541 /// // Transaction data for AMP API confirmation
10542 /// let tx_data = AmpTxData {
10543 /// details: serde_json::json!([{
10544 /// "account": "",
10545 /// "address": "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
10546 /// "category": "send",
10547 /// "amount": -100.0,
10548 /// "vout": 0,
10549 /// "fee": -0.001
10550 /// }]),
10551 /// txid: "abc123def456...".to_string(),
10552 /// };
10553 ///
10554 /// // Change UTXOs from Elements node listunspent call
10555 /// let change_data = vec![
10556 /// Unspent {
10557 /// txid: "abc123def456...".to_string(),
10558 /// vout: 1,
10559 /// amount: 25.0,
10560 /// asset: "asset_id_hex".to_string(),
10561 /// address: "change_address".to_string(),
10562 /// spendable: true,
10563 /// confirmations: Some(2),
10564 /// scriptpubkey: Some("76a914...88ac".to_string()),
10565 /// redeemscript: None,
10566 /// witnessscript: None,
10567 /// amountblinder: None,
10568 /// assetblinder: None,
10569 /// }
10570 /// ];
10571 ///
10572 /// client.confirm_distribution(asset_uuid, distribution_uuid, tx_data, change_data).await?;
10573 /// println!("Distribution confirmed successfully");
10574 /// # Ok(())
10575 /// # }
10576 /// ```
10577 ///
10578 /// # Related Methods
10579 /// - [`create_distribution`](Self::create_distribution) - Create a new distribution
10580 /// - [`get_asset_assignments`](Self::get_asset_assignments) - List assignments for an asset
10581 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
10582 pub async fn confirm_distribution(
10583 &self,
10584 asset_uuid: &str,
10585 distribution_uuid: &str,
10586 tx_data: crate::model::AmpTxData,
10587 change_data: Vec<crate::model::Unspent>,
10588 ) -> Result<(), AmpError> {
10589 use crate::model::ConfirmDistributionRequest;
10590
10591 let confirm_span = tracing::debug_span!(
10592 "confirm_distribution",
10593 asset_uuid = %asset_uuid,
10594 distribution_uuid = %distribution_uuid,
10595 txid = %tx_data.txid,
10596 change_count = change_data.len()
10597 );
10598 let _enter = confirm_span.enter();
10599
10600 tracing::debug!(
10601 "Confirming distribution {} for asset {} with txid {} ({} change UTXOs)",
10602 distribution_uuid,
10603 asset_uuid,
10604 tx_data.txid,
10605 change_data.len()
10606 );
10607
10608 // Validate inputs
10609 if asset_uuid.is_empty() {
10610 tracing::error!("Distribution confirmation failed: empty asset UUID");
10611 return Err(AmpError::validation("Asset UUID cannot be empty"));
10612 }
10613
10614 if distribution_uuid.is_empty() {
10615 tracing::error!("Distribution confirmation failed: empty distribution UUID");
10616 return Err(AmpError::validation("Distribution UUID cannot be empty"));
10617 }
10618
10619 if tx_data.txid.is_empty() {
10620 tracing::error!("Distribution confirmation failed: empty transaction ID");
10621 return Err(AmpError::validation("Transaction ID cannot be empty"));
10622 }
10623
10624 // Log transaction details for debugging
10625 tracing::debug!("Transaction details array: {:?}", tx_data.details);
10626
10627 // Log change data details
10628 if change_data.is_empty() {
10629 tracing::debug!("No change UTXOs to include in confirmation");
10630 } else {
10631 let total_change: f64 = change_data.iter().map(|utxo| utxo.amount).sum();
10632 tracing::debug!(
10633 "Change data - {} UTXOs, total amount: {}",
10634 change_data.len(),
10635 total_change
10636 );
10637
10638 for (i, utxo) in change_data.iter().enumerate() {
10639 tracing::trace!(
10640 "Change UTXO {}: txid={}, vout={}, amount={}, spendable={}",
10641 i,
10642 utxo.txid,
10643 utxo.vout,
10644 utxo.amount,
10645 utxo.spendable
10646 );
10647 }
10648 }
10649
10650 let request = ConfirmDistributionRequest {
10651 tx_data: tx_data.clone(),
10652 change_data: change_data.clone(),
10653 };
10654
10655 tracing::debug!("Sending distribution confirmation request to AMP API");
10656 let api_call_start = std::time::Instant::now();
10657
10658 // Make the API call
10659 self.request_empty(
10660 Method::POST,
10661 &["assets", asset_uuid, "distributions", distribution_uuid, "confirm"],
10662 Some(&request),
10663 )
10664 .await
10665 .map_err(#[allow(clippy::cognitive_complexity)] |e| {
10666 let api_call_duration = api_call_start.elapsed();
10667 let error_msg = format!(
10668 "Failed to confirm distribution {} after {:?}: {}. IMPORTANT: Transaction {} was successful on blockchain. Use this txid to manually retry confirmation.",
10669 distribution_uuid, api_call_duration, e, tx_data.txid
10670 );
10671 tracing::error!("{}", error_msg);
10672
10673 // Check for specific API error patterns
10674 let error_str = e.to_string();
10675 if error_str.contains("404") || error_str.contains("not found") {
10676 tracing::error!("Distribution {} not found - verify distribution UUID is correct", distribution_uuid);
10677 } else if error_str.contains("400") || error_str.contains("bad request") {
10678 tracing::error!("Bad request - check transaction data format and change data");
10679 } else if error_str.contains("409") || error_str.contains("conflict") {
10680 tracing::error!("Conflict - distribution may already be confirmed");
10681 } else if error_str.contains("422") || error_str.contains("unprocessable") {
10682 tracing::error!("Unprocessable entity - check transaction confirmations and data validity");
10683 } else if error_str.contains("500") || error_str.contains("internal server") {
10684 tracing::error!("Server error - this may be a temporary issue, retry with txid: {}", tx_data.txid);
10685 }
10686
10687 AmpError::api(error_msg)
10688 })?;
10689
10690 let api_call_duration = api_call_start.elapsed();
10691 tracing::info!(
10692 "Successfully confirmed distribution: {} for asset: {} with txid: {} (took {:?})",
10693 distribution_uuid,
10694 asset_uuid,
10695 tx_data.txid,
10696 api_call_duration
10697 );
10698
10699 Ok(())
10700 }
10701
10702 /// Cancels an in-progress distribution for an asset.
10703 ///
10704 /// This method cancels a distribution that is currently in progress (unconfirmed status).
10705 /// Once a distribution is cancelled, it cannot be confirmed and the assigned amounts
10706 /// become available for new distributions.
10707 ///
10708 /// # Arguments
10709 /// * `asset_uuid` - The UUID of the asset
10710 /// * `distribution_uuid` - The UUID of the distribution to cancel
10711 ///
10712 /// # Returns
10713 /// Returns `Ok(())` if the distribution was successfully cancelled.
10714 ///
10715 /// # Errors
10716 /// Returns an error if:
10717 /// - Authentication fails
10718 /// - The HTTP request fails
10719 /// - The server returns an error status
10720 /// - The distribution is not found
10721 /// - The distribution is already confirmed and cannot be cancelled
10722 ///
10723 /// # Examples
10724 /// ```no_run
10725 /// use amp_rs::ApiClient;
10726 ///
10727 /// #[tokio::main]
10728 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
10729 /// let client = ApiClient::new().await?;
10730 ///
10731 /// client.cancel_distribution(
10732 /// "asset-uuid-123",
10733 /// "distribution-uuid-456"
10734 /// ).await?;
10735 ///
10736 /// println!("Distribution cancelled successfully");
10737 /// Ok(())
10738 /// # }
10739 /// ```
10740 #[allow(clippy::cognitive_complexity)]
10741 pub async fn cancel_distribution(
10742 &self,
10743 asset_uuid: &str,
10744 distribution_uuid: &str,
10745 ) -> Result<(), AmpError> {
10746 let cancel_span = tracing::debug_span!(
10747 "cancel_distribution",
10748 asset_uuid = %asset_uuid,
10749 distribution_uuid = %distribution_uuid
10750 );
10751 let _enter = cancel_span.enter();
10752
10753 tracing::debug!(
10754 "Cancelling distribution {} for asset {}",
10755 distribution_uuid,
10756 asset_uuid
10757 );
10758
10759 // Validate inputs
10760 if asset_uuid.is_empty() {
10761 tracing::error!("Distribution cancellation failed: empty asset UUID");
10762 return Err(AmpError::validation("Asset UUID cannot be empty"));
10763 }
10764
10765 if distribution_uuid.is_empty() {
10766 tracing::error!("Distribution cancellation failed: empty distribution UUID");
10767 return Err(AmpError::validation("Distribution UUID cannot be empty"));
10768 }
10769
10770 let api_call_start = std::time::Instant::now();
10771
10772 self.request_empty(
10773 Method::DELETE,
10774 &[
10775 "assets",
10776 asset_uuid,
10777 "distributions",
10778 distribution_uuid,
10779 "cancel",
10780 ],
10781 None::<&()>,
10782 )
10783 .await
10784 .map_err(|e| {
10785 let api_call_duration = api_call_start.elapsed();
10786 let error_msg = format!(
10787 "Failed to cancel distribution {distribution_uuid} for asset {asset_uuid} after {api_call_duration:?}: {e}"
10788 );
10789 tracing::error!("{}", error_msg);
10790
10791 // Check for specific API error patterns
10792 let error_str = e.to_string();
10793 if error_str.contains("404") || error_str.contains("not found") {
10794 tracing::error!(
10795 "Distribution {} not found - verify distribution UUID is correct",
10796 distribution_uuid
10797 );
10798 } else if error_str.contains("400") || error_str.contains("bad request") {
10799 tracing::error!("Bad request - distribution may already be confirmed or invalid");
10800 } else if error_str.contains("409") || error_str.contains("conflict") {
10801 tracing::error!(
10802 "Conflict - distribution may already be confirmed and cannot be cancelled"
10803 );
10804 } else if error_str.contains("422") || error_str.contains("unprocessable") {
10805 tracing::error!(
10806 "Unprocessable entity - distribution is in a state that cannot be cancelled"
10807 );
10808 }
10809
10810 AmpError::api(error_msg)
10811 })?;
10812
10813 let api_call_duration = api_call_start.elapsed();
10814 tracing::info!(
10815 "Successfully cancelled distribution: {} for asset: {} (took {:?})",
10816 distribution_uuid,
10817 asset_uuid,
10818 api_call_duration
10819 );
10820
10821 Ok(())
10822 }
10823
10824 /// Gets all distributions for a specific asset.
10825 ///
10826 /// This method retrieves all distributions (both confirmed and unconfirmed) for the specified asset.
10827 /// This is useful for checking if there are any in-progress distributions before deleting an asset.
10828 ///
10829 /// # Arguments
10830 /// * `asset_uuid` - The UUID of the asset to get distributions for
10831 ///
10832 /// # Returns
10833 /// Returns a vector of `Distribution` objects for the asset.
10834 ///
10835 /// # Errors
10836 /// Returns an error if:
10837 /// - Authentication fails
10838 /// - The HTTP request fails
10839 /// - The server returns an error status
10840 /// - The response cannot be parsed
10841 ///
10842 /// # Examples
10843 /// ```no_run
10844 /// use amp_rs::ApiClient;
10845 ///
10846 /// #[tokio::main]
10847 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
10848 /// let client = ApiClient::new().await?;
10849 ///
10850 /// let distributions = client.get_asset_distributions("asset-uuid-123").await?;
10851 ///
10852 /// for distribution in distributions {
10853 /// println!("Distribution: {} - Status: {:?}",
10854 /// distribution.distribution_uuid,
10855 /// distribution.distribution_status);
10856 /// }
10857 /// Ok(())
10858 /// }
10859 /// ```
10860 pub async fn get_asset_distributions(
10861 &self,
10862 asset_uuid: &str,
10863 ) -> Result<Vec<crate::model::Distribution>, Error> {
10864 let distributions_span = tracing::debug_span!(
10865 "get_asset_distributions",
10866 asset_uuid = %asset_uuid
10867 );
10868 let _enter = distributions_span.enter();
10869
10870 tracing::debug!("Getting distributions for asset {}", asset_uuid);
10871
10872 // Validate input
10873 if asset_uuid.is_empty() {
10874 tracing::error!("Get distributions failed: empty asset UUID");
10875 return Err(Error::RequestFailed(
10876 "Asset UUID cannot be empty".to_string(),
10877 ));
10878 }
10879
10880 self.request_json(
10881 Method::GET,
10882 &["assets", asset_uuid, "distributions"],
10883 None::<&()>,
10884 )
10885 .await
10886 }
10887
10888 /// Gets a specific distribution by UUID for an asset.
10889 ///
10890 /// This method retrieves detailed information about a specific distribution,
10891 /// including its status, UUID, and associated transactions.
10892 ///
10893 /// # Arguments
10894 /// * `asset_uuid` - The UUID of the asset
10895 /// * `distribution_uuid` - The UUID of the distribution to retrieve
10896 ///
10897 /// # Returns
10898 /// Returns a `Distribution` struct containing:
10899 /// - `distribution_uuid` - The unique identifier for the distribution
10900 /// - `distribution_status` - Current status of the distribution
10901 /// - `transactions` - List of transactions associated with the distribution
10902 ///
10903 /// # Errors
10904 /// Returns an error if:
10905 /// - Authentication fails
10906 /// - The HTTP request fails
10907 /// - The server returns an error status
10908 /// - The response cannot be parsed as JSON
10909 /// - The asset UUID or distribution UUID is empty
10910 ///
10911 /// # Examples
10912 /// ```no_run
10913 /// # use amp_rs::ApiClient;
10914 /// # #[tokio::main]
10915 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10916 /// let client = ApiClient::new().await?;
10917 ///
10918 /// let distribution = client.get_asset_distribution(
10919 /// "asset-uuid-123",
10920 /// "distribution-uuid-456"
10921 /// ).await?;
10922 ///
10923 /// println!("Distribution: {} - Status: {:?}",
10924 /// distribution.distribution_uuid,
10925 /// distribution.distribution_status);
10926 /// # Ok(())
10927 /// # }
10928 /// ```
10929 ///
10930 /// # Related Methods
10931 /// - [`get_asset_distributions`](Self::get_asset_distributions) - List all distributions for an asset
10932 /// - [`create_distribution`](Self::create_distribution) - Create a new distribution
10933 /// - [`confirm_distribution`](Self::confirm_distribution) - Confirm a distribution
10934 /// - [`cancel_distribution`](Self::cancel_distribution) - Cancel a distribution
10935 #[allow(clippy::cognitive_complexity)]
10936 pub async fn get_asset_distribution(
10937 &self,
10938 asset_uuid: &str,
10939 distribution_uuid: &str,
10940 ) -> Result<crate::model::Distribution, Error> {
10941 let distribution_span = tracing::debug_span!(
10942 "get_asset_distribution",
10943 asset_uuid = %asset_uuid,
10944 distribution_uuid = %distribution_uuid
10945 );
10946 let _enter = distribution_span.enter();
10947
10948 tracing::debug!(
10949 "Getting distribution {} for asset {}",
10950 distribution_uuid,
10951 asset_uuid
10952 );
10953
10954 // Validate inputs
10955 if asset_uuid.is_empty() {
10956 tracing::error!("Get distribution failed: empty asset UUID");
10957 return Err(Error::RequestFailed(
10958 "Asset UUID cannot be empty".to_string(),
10959 ));
10960 }
10961
10962 if distribution_uuid.is_empty() {
10963 tracing::error!("Get distribution failed: empty distribution UUID");
10964 return Err(Error::RequestFailed(
10965 "Distribution UUID cannot be empty".to_string(),
10966 ));
10967 }
10968
10969 self.request_json(
10970 Method::GET,
10971 &["assets", asset_uuid, "distributions", distribution_uuid],
10972 None::<&()>,
10973 )
10974 .await
10975 }
10976
10977 /// Gets a specific manager by ID.
10978 ///
10979 /// # Arguments
10980 /// * `manager_id` - The ID of the manager to retrieve
10981 ///
10982 /// # Errors
10983 /// Returns an error if:
10984 /// - Authentication fails
10985 /// - The HTTP request fails
10986 /// - The server returns an error status
10987 /// - The response cannot be parsed as JSON
10988 pub async fn get_manager(&self, manager_id: i64) -> Result<crate::model::Manager, Error> {
10989 self.request_json(
10990 Method::GET,
10991 &["managers", &manager_id.to_string()],
10992 None::<&()>,
10993 )
10994 .await
10995 }
10996
10997 /// Removes a manager's permissions to modify a specific asset.
10998 ///
10999 /// This method revokes a manager's access to a specific asset, preventing them from
11000 /// performing asset management operations such as creating assignments, managing ownership,
11001 /// or modifying asset properties. The manager will no longer be able to access this asset
11002 /// through their management interface.
11003 ///
11004 /// # Arguments
11005 /// * `manager_id` - The ID of the manager to remove permissions from
11006 /// * `asset_uuid` - The UUID of the asset to remove permissions for
11007 ///
11008 /// # Returns
11009 /// Returns `Ok(())` on successful permission removal.
11010 ///
11011 /// # Errors
11012 /// Returns an error if:
11013 /// - Authentication fails or insufficient permissions
11014 /// - The manager ID is invalid or does not exist
11015 /// - The asset UUID is invalid or does not exist
11016 /// - The manager does not currently have permissions for this asset
11017 /// - The HTTP request fails
11018 /// - The server returns an error status
11019 ///
11020 /// # Examples
11021 /// ```no_run
11022 /// # use amp_rs::ApiClient;
11023 /// # #[tokio::main]
11024 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
11025 /// let client = ApiClient::new().await?;
11026 ///
11027 /// let manager_id = 123;
11028 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
11029 ///
11030 /// client.manager_remove_asset(manager_id, asset_uuid).await?;
11031 /// println!("Removed asset {} from manager {}", asset_uuid, manager_id);
11032 /// # Ok(())
11033 /// # }
11034 /// ```
11035 ///
11036 /// # Related Methods
11037 /// - [`add_asset_to_manager`](Self::add_asset_to_manager) - Grant manager permissions for an asset
11038 /// - [`get_manager`](Self::get_manager) - Get manager information including current assets
11039 /// - [`revoke_manager`](Self::revoke_manager) - Remove all asset permissions from manager
11040 /// - [`lock_manager`](Self::lock_manager) - Lock manager account
11041 pub async fn manager_remove_asset(
11042 &self,
11043 manager_id: i64,
11044 asset_uuid: &str,
11045 ) -> Result<(), Error> {
11046 self.request_empty(
11047 Method::POST,
11048 &[
11049 "managers",
11050 &manager_id.to_string(),
11051 "assets",
11052 asset_uuid,
11053 "remove",
11054 ],
11055 None::<&()>,
11056 )
11057 .await
11058 }
11059
11060 /// Revokes all asset permissions for a manager.
11061 ///
11062 /// This method first retrieves the manager's current asset permissions,
11063 /// then removes the manager's access to each asset they currently have access to.
11064 ///
11065 /// # Arguments
11066 /// * `manager_id` - The ID of the manager to revoke permissions for
11067 ///
11068 /// # Errors
11069 /// Returns an error if:
11070 /// - Authentication fails
11071 /// - The HTTP request fails
11072 /// - The server returns an error status
11073 /// - Any individual asset removal fails
11074 pub async fn revoke_manager(&self, manager_id: i64) -> Result<(), Error> {
11075 // First, get the manager to see which assets they have access to
11076 let manager = self.get_manager(manager_id).await?;
11077
11078 // Remove the manager's access to each asset
11079 for asset_uuid in &manager.assets {
11080 self.manager_remove_asset(manager_id, asset_uuid).await?;
11081 }
11082
11083 Ok(())
11084 }
11085
11086 /// Gets the current manager information as raw JSON.
11087 ///
11088 /// This method calls the `/managers/me` endpoint to retrieve information
11089 /// about the currently authenticated manager.
11090 ///
11091 /// # Errors
11092 /// Returns an error if:
11093 /// - Authentication fails
11094 /// - The HTTP request fails
11095 /// - The server returns an error status
11096 /// - The response cannot be parsed as JSON
11097 pub async fn get_current_manager_raw(&self) -> Result<serde_json::Value, Error> {
11098 self.request_json(Method::GET, &["managers", "me"], None::<&()>)
11099 .await
11100 }
11101
11102 /// Locks a manager account to prevent further operations.
11103 ///
11104 /// This method sends a PUT request to lock the specified manager, preventing any further
11105 /// operations on that manager account. This is typically used for security purposes or
11106 /// when a manager needs to be temporarily disabled.
11107 ///
11108 /// # Arguments
11109 /// * `manager_id` - The ID of the manager to lock
11110 ///
11111 /// # Returns
11112 /// Returns `Ok(())` if the manager was successfully locked.
11113 ///
11114 /// # Errors
11115 /// Returns an error if:
11116 /// - Authentication fails
11117 /// - The HTTP request fails
11118 /// - The server returns an error status
11119 /// - The manager ID is invalid or does not exist
11120 /// - The manager is already locked
11121 ///
11122 /// # Example
11123 /// ```no_run
11124 /// # use amp_rs::ApiClient;
11125 /// # #[tokio::main]
11126 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
11127 /// let client = ApiClient::new().await?;
11128 ///
11129 /// // Lock manager with ID 123
11130 /// client.lock_manager(123).await?;
11131 /// println!("Manager 123 has been locked successfully");
11132 /// # Ok(())
11133 /// # }
11134 /// ```
11135 pub async fn lock_manager(&self, manager_id: i64) -> Result<(), Error> {
11136 self.request_empty(
11137 Method::PUT,
11138 &["managers", &manager_id.to_string(), "lock"],
11139 None::<&()>,
11140 )
11141 .await
11142 }
11143
11144 /// Unlocks a manager account.
11145 ///
11146 /// # Arguments
11147 /// * `manager_id` - The ID of the manager to unlock
11148 ///
11149 /// # Errors
11150 /// Returns an error if:
11151 /// - Authentication fails
11152 /// - The HTTP request fails
11153 /// - The server returns an error status
11154 pub async fn unlock_manager(&self, manager_id: i64) -> Result<(), Error> {
11155 self.request_empty(
11156 Method::PUT,
11157 &["managers", &manager_id.to_string(), "unlock"],
11158 None::<&()>,
11159 )
11160 .await
11161 }
11162
11163 /// Authorizes a manager to manage a specific asset.
11164 ///
11165 /// This method sends a PUT request to authorize the specified manager to manage the given asset.
11166 /// Once authorized, the manager will have permissions to perform operations on the asset such as
11167 /// creating assignments, managing ownership, and other asset-related operations.
11168 ///
11169 /// # Arguments
11170 /// * `manager_id` - The ID of the manager to authorize
11171 /// * `asset_uuid` - The UUID of the asset to add to the manager's authorized assets
11172 ///
11173 /// # Returns
11174 /// Returns `Ok(())` if the manager was successfully authorized for the asset.
11175 ///
11176 /// # Errors
11177 /// Returns an error if:
11178 /// - Authentication fails or insufficient permissions
11179 /// - The HTTP request fails
11180 /// - The server returns an error status
11181 /// - The manager ID is invalid or does not exist
11182 /// - The asset UUID is invalid or does not exist
11183 /// - The manager is already authorized for this asset
11184 /// - The manager is locked and cannot be modified
11185 ///
11186 /// # Examples
11187 /// ```no_run
11188 /// # use amp_rs::ApiClient;
11189 /// # #[tokio::main]
11190 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
11191 /// let client = ApiClient::new().await?;
11192 ///
11193 /// // Authorize manager 123 to manage asset with UUID "550e8400-e29b-41d4-a716-446655440000"
11194 /// let manager_id = 123;
11195 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
11196 ///
11197 /// client.add_asset_to_manager(manager_id, asset_uuid).await?;
11198 /// println!("Manager {} is now authorized to manage asset {}", manager_id, asset_uuid);
11199 /// # Ok(())
11200 /// # }
11201 /// ```
11202 ///
11203 /// # Related Methods
11204 /// - [`manager_remove_asset`](Self::manager_remove_asset) - Remove manager permissions for an asset
11205 /// - [`get_manager`](Self::get_manager) - Get manager information including current assets
11206 /// - [`get_manager_permissions`](Self::get_manager_permissions) - Get manager's current permissions
11207 /// - [`lock_manager`](Self::lock_manager) - Lock manager account
11208 pub async fn add_asset_to_manager(
11209 &self,
11210 manager_id: i64,
11211 asset_uuid: &str,
11212 ) -> Result<(), Error> {
11213 self.request_empty(
11214 Method::PUT,
11215 &[
11216 "managers",
11217 &manager_id.to_string(),
11218 "assets",
11219 asset_uuid,
11220 "add",
11221 ],
11222 None::<&()>,
11223 )
11224 .await
11225 }
11226
11227 /// Deletes a specific asset assignment.
11228 ///
11229 /// # Arguments
11230 /// * `asset_uuid` - The UUID of the asset
11231 /// * `assignment_id` - The ID of the assignment to delete
11232 ///
11233 /// # Errors
11234 /// Returns an error if:
11235 /// - Authentication fails
11236 /// - The HTTP request fails
11237 /// - The server returns an error status
11238 /// Removes an asset assignment.
11239 ///
11240 /// This method permanently deletes an asset assignment, returning the allocated assets
11241 /// back to the available pool. This operation cannot be undone. If the assignment has
11242 /// already been distributed, this operation may fail.
11243 ///
11244 /// # Arguments
11245 /// * `asset_uuid` - The UUID of the asset containing the assignment
11246 /// * `assignment_id` - The ID of the assignment to delete
11247 ///
11248 /// # Returns
11249 /// Returns `Ok(())` on successful deletion.
11250 ///
11251 /// # Errors
11252 /// Returns an error if:
11253 /// - Authentication fails or insufficient permissions
11254 /// - The asset UUID is invalid or does not exist
11255 /// - The assignment ID is invalid or does not exist
11256 /// - The assignment has already been distributed and cannot be deleted
11257 /// - The assignment is locked and cannot be modified
11258 /// - The HTTP request fails
11259 /// - The server returns an error status
11260 ///
11261 /// # Examples
11262 /// ```no_run
11263 /// # use amp_rs::ApiClient;
11264 /// # #[tokio::main]
11265 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
11266 /// let client = ApiClient::new().await?;
11267 ///
11268 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
11269 /// let assignment_id = "123";
11270 ///
11271 /// client.delete_asset_assignment(asset_uuid, assignment_id).await?;
11272 /// println!("Successfully deleted assignment {}", assignment_id);
11273 /// # Ok(())
11274 /// # }
11275 /// ```
11276 ///
11277 /// # Related Methods
11278 /// - [`get_asset_assignment`](Self::get_asset_assignment) - Get assignment details before deletion
11279 /// - [`create_asset_assignments`](Self::create_asset_assignments) - Create new assignments
11280 /// - [`edit_asset_assignment`](Self::edit_asset_assignment) - Update assignment instead of deleting
11281 /// - [`lock_asset_assignment`](Self::lock_asset_assignment) - Lock assignment to prevent changes
11282 pub async fn delete_asset_assignment(
11283 &self,
11284 asset_uuid: &str,
11285 assignment_id: &str,
11286 ) -> Result<(), Error> {
11287 self.request_empty(
11288 Method::DELETE,
11289 &["assets", asset_uuid, "assignments", assignment_id, "delete"],
11290 None::<&()>,
11291 )
11292 .await
11293 }
11294
11295 /// Locks a specific asset assignment.
11296 ///
11297 /// # Arguments
11298 /// * `asset_uuid` - The UUID of the asset
11299 /// * `assignment_id` - The ID of the assignment to lock
11300 ///
11301 /// # Errors
11302 /// Returns an error if:
11303 /// - Authentication fails
11304 /// - The HTTP request fails
11305 /// - The server returns an error status
11306 pub async fn lock_asset_assignment(
11307 &self,
11308 asset_uuid: &str,
11309 assignment_id: &str,
11310 ) -> Result<Assignment, Error> {
11311 self.request_json(
11312 Method::PUT,
11313 &["assets", asset_uuid, "assignments", assignment_id, "lock"],
11314 None::<&()>,
11315 )
11316 .await
11317 }
11318
11319 /// Unlocks a specific asset assignment.
11320 ///
11321 /// # Arguments
11322 /// * `asset_uuid` - The UUID of the asset
11323 /// * `assignment_id` - The ID of the assignment to unlock
11324 ///
11325 /// # Errors
11326 /// Returns an error if:
11327 /// - Authentication fails
11328 /// - The HTTP request fails
11329 /// - The server returns an error status
11330 pub async fn unlock_asset_assignment(
11331 &self,
11332 asset_uuid: &str,
11333 assignment_id: &str,
11334 ) -> Result<Assignment, Error> {
11335 self.request_json(
11336 Method::PUT,
11337 &["assets", asset_uuid, "assignments", assignment_id, "unlock"],
11338 None::<&()>,
11339 )
11340 .await
11341 }
11342
11343 /// Adds categories to a registered user.
11344 ///
11345 /// # Arguments
11346 /// * `registered_user_id` - The ID of the registered user
11347 /// * `categories` - A slice of category IDs to add to the user
11348 ///
11349 /// # Errors
11350 /// Returns an error if:
11351 /// - Authentication fails
11352 /// - The HTTP request fails
11353 /// - The server returns an error status
11354 /// - The registered user ID is invalid
11355 /// - Any category ID is invalid
11356 pub async fn add_categories_to_registered_user(
11357 &self,
11358 registered_user_id: i64,
11359 categories: &[i64],
11360 ) -> Result<(), Error> {
11361 let request_body = CategoriesRequest {
11362 categories: categories.to_vec(),
11363 };
11364
11365 self.request_empty(
11366 Method::PUT,
11367 &[
11368 "registered_users",
11369 ®istered_user_id.to_string(),
11370 "categories",
11371 "add",
11372 ],
11373 Some(request_body),
11374 )
11375 .await
11376 }
11377
11378 /// Removes categories from a registered user
11379 ///
11380 /// # Arguments
11381 /// * `registered_user_id` - The ID of the registered user
11382 /// * `categories` - A slice of category IDs to remove from the user
11383 ///
11384 /// # Returns
11385 /// Returns `Ok(())` if the categories are successfully removed, or an error if:
11386 /// - Authentication fails
11387 /// - The HTTP request fails
11388 /// - The server returns an error status
11389 /// - The registered user ID is invalid
11390 /// - Any category ID is not associated with the user
11391 pub async fn remove_categories_from_registered_user(
11392 &self,
11393 registered_user_id: i64,
11394 categories: &[i64],
11395 ) -> Result<(), Error> {
11396 let request_body = CategoriesRequest {
11397 categories: categories.to_vec(),
11398 };
11399
11400 self.request_empty(
11401 Method::PUT,
11402 &[
11403 "registered_users",
11404 ®istered_user_id.to_string(),
11405 "categories",
11406 "delete",
11407 ],
11408 Some(request_body),
11409 )
11410 .await
11411 }
11412
11413 /// Distributes assets to multiple users through a comprehensive workflow
11414 ///
11415 /// This method orchestrates the complete asset distribution process:
11416 /// 1. Validates input parameters (asset UUID format, assignments structure)
11417 /// 2. Verifies `ElementsRpc` connection and signer interface availability
11418 /// 3. Authenticates with the AMP API using the client's token
11419 /// 4. Creates a distribution request via the AMP API
11420 /// 5. Constructs and signs the blockchain transaction using the provided signer
11421 /// 6. Broadcasts the transaction to the Elements network
11422 /// 7. Waits for blockchain confirmations (2 confirmations minimum)
11423 /// 8. Confirms the distribution with the AMP API
11424 ///
11425 /// # Arguments
11426 /// * `asset_uuid` - The UUID of the asset to distribute (must be valid UUID format)
11427 /// * `assignments` - Vector of assignments specifying `user_id`, address, and amount
11428 /// * `node_rpc` - `ElementsRpc` client for blockchain operations
11429 /// * `signer` - Signer implementation for transaction signing
11430 ///
11431 /// # Returns
11432 /// Returns `Ok(())` if the distribution completes successfully, or an `AmpError` if:
11433 /// - Input validation fails (invalid UUID format, empty assignments, etc.)
11434 /// - `ElementsRpc` connection cannot be established
11435 /// - Signer interface is not available
11436 /// - Authentication with AMP API fails
11437 /// - Distribution creation fails
11438 /// - Transaction construction or signing fails
11439 /// - Blockchain broadcasting fails
11440 /// - Confirmation timeout occurs
11441 /// - Distribution confirmation with AMP API fails
11442 ///
11443 /// # Examples
11444 /// ```no_run
11445 /// # use amp_rs::{ApiClient, ElementsRpc, AmpError};
11446 /// # use amp_rs::model::AssetDistributionAssignment;
11447 /// # use amp_rs::signer::{Signer, LwkSoftwareSigner};
11448 /// # #[tokio::main]
11449 /// # async fn main() -> Result<(), AmpError> {
11450 /// let client = ApiClient::new().await?;
11451 /// let elements_rpc = ElementsRpc::from_env()?;
11452 /// let (_, signer) = LwkSoftwareSigner::generate_new()?;
11453 ///
11454 /// let assignments = vec![
11455 /// AssetDistributionAssignment {
11456 /// user_id: "user123".to_string(),
11457 /// address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
11458 /// amount: 100.0,
11459 /// },
11460 /// ];
11461 ///
11462 /// client.distribute_asset(
11463 /// "550e8400-e29b-41d4-a716-446655440000",
11464 /// assignments,
11465 /// &elements_rpc,
11466 /// "wallet_name",
11467 /// &signer
11468 /// ).await?;
11469 /// # Ok(())
11470 /// # }
11471 /// ```
11472 ///
11473 /// # Requirements
11474 /// This method implements requirements:
11475 /// - 1.1: Single method for complete distribution workflow
11476 /// - 2.2: Assignment details validation
11477 /// - 2.4: Input validation for all parameters
11478 /// - 5.1: Comprehensive error handling with context
11479 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
11480 pub async fn distribute_asset(
11481 &self,
11482 asset_uuid: &str,
11483 assignments: Vec<AssetDistributionAssignment>,
11484 node_rpc: &ElementsRpc,
11485 wallet_name: &str,
11486 signer: &dyn Signer,
11487 ) -> Result<(), AmpError> {
11488 let distribution_span = tracing::info_span!(
11489 "distribute_asset",
11490 asset_uuid = %asset_uuid,
11491 assignment_count = assignments.len()
11492 );
11493 let _enter = distribution_span.enter();
11494
11495 tracing::info!(
11496 "Starting asset distribution workflow for asset: {} with {} assignments",
11497 asset_uuid,
11498 assignments.len()
11499 );
11500
11501 // Step 1: Input validation - asset_uuid format
11502 tracing::debug!("Step 1: Validating asset UUID format");
11503 Self::validate_asset_uuid(asset_uuid).map_err(|e| {
11504 let error = AmpError::validation(format!("Invalid asset UUID: {e}"));
11505 tracing::error!("Asset UUID validation failed: {}", e);
11506 error.with_context("Step 1: Asset UUID validation")
11507 })?;
11508 tracing::debug!("Asset UUID validation passed");
11509
11510 // Step 2: Input validation - assignments data structure
11511 tracing::debug!("Step 2: Validating {} assignments", assignments.len());
11512 Self::validate_assignments(&assignments).map_err(|e| {
11513 let error = AmpError::validation(format!("Invalid assignments: {e}"));
11514 tracing::error!("Assignments validation failed: {}", e);
11515 error.with_context("Step 2: Assignments validation")
11516 })?;
11517 tracing::debug!("Assignments validation passed");
11518
11519 // Step 3: Check ElementsRpc connection availability
11520 tracing::debug!("Step 3: Validating Elements RPC connection");
11521 self.validate_elements_rpc_connection(node_rpc)
11522 .await
11523 .map_err(|e| {
11524 let error = AmpError::rpc(format!("ElementsRpc connection validation failed: {e}"));
11525 tracing::error!("Elements RPC connection validation failed: {}", e);
11526 error.with_context("Step 3: Elements RPC connection validation")
11527 })?;
11528 tracing::debug!("Elements RPC connection validation passed");
11529
11530 // Step 4: Check signer interface availability
11531 tracing::debug!("Step 4: Validating signer interface");
11532 self.validate_signer_interface(signer).await.map_err(|e| {
11533 let error = AmpError::validation(format!("Signer interface validation failed: {e}"));
11534 tracing::error!("Signer interface validation failed: {}", e);
11535 error.with_context("Step 4: Signer interface validation")
11536 })?;
11537 tracing::debug!("Signer interface validation passed");
11538
11539 tracing::info!("✓ All input validations completed successfully");
11540
11541 // Step 5: Authenticate with AMP API using existing TokenManager
11542 tracing::debug!("Step 5: Authenticating with AMP API");
11543 let _token = self.token_strategy.get_token().await.map_err(|e| {
11544 tracing::error!("AMP API authentication failed: {}", e);
11545 let amp_error = AmpError::Existing(e);
11546 if amp_error.is_retryable() {
11547 if let Some(instructions) = amp_error.retry_instructions() {
11548 tracing::warn!("Retry instructions: {}", instructions);
11549 }
11550 }
11551 amp_error.with_context("Step 5: AMP API authentication")
11552 })?;
11553 tracing::info!("✓ Successfully authenticated with AMP API");
11554
11555 // Step 6: Create distribution request and parse response data
11556 tracing::debug!(
11557 "Step 6: Creating distribution request with {} assignments",
11558 assignments.len()
11559 );
11560 let distribution_response = self
11561 .create_distribution(asset_uuid, assignments)
11562 .await
11563 .map_err(|e| {
11564 tracing::error!("Distribution creation failed: {}", e);
11565 if e.is_retryable() {
11566 if let Some(instructions) = e.retry_instructions() {
11567 tracing::warn!("Retry instructions: {}", instructions);
11568 }
11569 }
11570 e.with_context("Step 6: Distribution creation")
11571 })?;
11572
11573 tracing::info!(
11574 "✓ Distribution created successfully: {} with asset_id: {}",
11575 distribution_response.distribution_uuid,
11576 distribution_response.asset_id
11577 );
11578
11579 // Step 7: Verify Elements node status and execute transaction workflow
11580 tracing::debug!("Step 7: Verifying Elements node status");
11581 let (network_info, blockchain_info) = node_rpc.get_node_status().await.map_err(|e| {
11582 tracing::error!("Elements node status verification failed: {}", e);
11583 if e.is_retryable() {
11584 if let Some(instructions) = e.retry_instructions() {
11585 tracing::warn!("Retry instructions: {}", instructions);
11586 }
11587 }
11588 e.with_context("Step 7: Elements node status verification")
11589 })?;
11590
11591 tracing::info!(
11592 "✓ Elements node verified - chain: {}, blocks: {}, connections: {}",
11593 blockchain_info.chain,
11594 blockchain_info.blocks,
11595 network_info.connections
11596 );
11597
11598 // Step 8: Send distribution transaction using Elements' sendmany
11599 tracing::debug!("Step 8: Sending distribution transaction using Elements sendmany");
11600
11601 // Create asset amounts map for sendmany (all outputs use the same asset)
11602 let mut asset_amounts = std::collections::HashMap::new();
11603 for address in distribution_response.map_address_amount.keys() {
11604 asset_amounts.insert(address.clone(), distribution_response.asset_id.clone());
11605 }
11606
11607 tracing::info!(
11608 "Using sendmany for {} outputs with asset {}",
11609 distribution_response.map_address_amount.len(),
11610 distribution_response.asset_id
11611 );
11612
11613 // Use Elements' sendmany which properly handles confidential transactions
11614 let txid = node_rpc
11615 .sendmany(
11616 wallet_name,
11617 distribution_response.map_address_amount.clone(),
11618 asset_amounts,
11619 Some(0), // min_conf: 0 to include unconfirmed UTXOs (matches Python implementation)
11620 Some("AMP asset distribution"), // comment
11621 None, // subtract_fee_from: let Elements handle fees automatically
11622 Some(false), // replaceable: false for final transactions
11623 Some(1), // conf_target: 1 block for faster confirmation
11624 Some("UNSET"), // estimate_mode: let Elements choose
11625 )
11626 .await
11627 .map_err(|e| {
11628 tracing::error!("Sendmany transaction failed: {}", e);
11629 if e.is_retryable() {
11630 if let Some(instructions) = e.retry_instructions() {
11631 tracing::warn!("Retry instructions: {}", instructions);
11632 }
11633 }
11634 e.with_context("Step 8: Sendmany transaction")
11635 })?;
11636
11637 tracing::info!("✓ Transaction sent successfully with ID: {}", txid);
11638
11639 // Step 9: Wait for confirmations
11640 tracing::debug!("Step 9: Waiting for blockchain confirmations (minimum 2 confirmations, 10-minute timeout)");
11641 let confirmation_start = std::time::Instant::now();
11642 let tx_detail = node_rpc.wait_for_confirmations(&txid, Some(2), Some(10)).await
11643 .map_err(|e| {
11644 let elapsed = confirmation_start.elapsed();
11645 tracing::error!(
11646 "Confirmation waiting failed after {:?}: {}",
11647 elapsed,
11648 e
11649 );
11650
11651 if let AmpError::Timeout(_) = &e {
11652 tracing::warn!(
11653 "Confirmation timeout - transaction {} may still be pending. \
11654 Use this txid to manually confirm the distribution if it gets confirmed later.",
11655 txid
11656 );
11657 let timeout_error = AmpError::timeout(format!(
11658 "Confirmation timeout for txid: {txid}. Use this txid to manually confirm the distribution."
11659 ));
11660 timeout_error.with_context("Step 9: Confirmation waiting")
11661 } else {
11662 if e.is_retryable() {
11663 if let Some(instructions) = e.retry_instructions() {
11664 tracing::warn!("Retry instructions: {}", instructions);
11665 }
11666 }
11667 e.with_context(format!("Step 9: Confirmation waiting for txid: {txid}"))
11668 }
11669 })?;
11670
11671 let confirmation_duration = confirmation_start.elapsed();
11672 tracing::info!(
11673 "✓ Transaction confirmed with {} confirmations at block height: {:?} (took {:?})",
11674 tx_detail.confirmations,
11675 tx_detail.blockheight,
11676 confirmation_duration
11677 );
11678
11679 // Step 10: Collect change data for confirmation
11680 tracing::debug!("Step 10: Collecting change data for distribution confirmation");
11681 let change_data = node_rpc
11682 .collect_change_data(
11683 &distribution_response.asset_id,
11684 &txid,
11685 node_rpc,
11686 wallet_name,
11687 )
11688 .await
11689 .map_err(|e| {
11690 tracing::error!("Change data collection failed: {}", e);
11691 if e.is_retryable() {
11692 if let Some(instructions) = e.retry_instructions() {
11693 tracing::warn!("Retry instructions: {}", instructions);
11694 }
11695 }
11696 e.with_context("Step 10: Change data collection")
11697 })?;
11698
11699 tracing::info!("✓ Collected {} change UTXOs", change_data.len());
11700 if !change_data.is_empty() {
11701 tracing::debug!("Change UTXOs: {:?}", change_data);
11702 }
11703
11704 // Step 11: Submit final confirmation to AMP API
11705 tracing::debug!("Step 11: Submitting final confirmation to AMP API");
11706
11707 // Extract the details field from the transaction (matching Python implementation)
11708 // Python: details = rpc.call('gettransaction', txid).get('details')
11709 let transaction_details = tx_detail.details.unwrap_or_else(Vec::new);
11710 tracing::debug!(
11711 "Transaction details for confirmation: {:?}",
11712 transaction_details
11713 );
11714
11715 let amp_tx_data = crate::model::AmpTxData {
11716 details: serde_json::Value::Array(transaction_details),
11717 txid: txid.clone(),
11718 };
11719
11720 // Log the exact payload being sent to AMP for debugging
11721 tracing::info!("Sending confirmation payload to AMP:");
11722 tracing::info!(" tx_data.txid: {}", amp_tx_data.txid);
11723 tracing::info!(" tx_data.details: {:?}", amp_tx_data.details);
11724 tracing::info!(" change_data: {} UTXOs", change_data.len());
11725
11726 let confirmation_request = crate::model::ConfirmDistributionRequest {
11727 tx_data: amp_tx_data.clone(),
11728 change_data: change_data.clone(),
11729 };
11730
11731 if let Ok(payload_json) = serde_json::to_string_pretty(&confirmation_request) {
11732 tracing::debug!("Full confirmation payload: {}", payload_json);
11733 }
11734
11735 self.confirm_distribution(
11736 asset_uuid,
11737 &distribution_response.distribution_uuid,
11738 amp_tx_data,
11739 change_data,
11740 )
11741 .await
11742 .map_err(|e| {
11743 tracing::error!("Distribution confirmation failed: {}", e);
11744
11745 // For confirmation failures, always provide retry instructions with txid
11746 let confirmation_error = AmpError::api(format!(
11747 "Failed to confirm distribution {}: {}. \
11748 IMPORTANT: Transaction {} was successful on blockchain. \
11749 Use this txid to manually retry confirmation.",
11750 distribution_response.distribution_uuid, e, txid
11751 ));
11752
11753 if e.is_retryable() {
11754 if let Some(instructions) = e.retry_instructions() {
11755 tracing::warn!("Retry instructions: {}", instructions);
11756 }
11757 }
11758
11759 confirmation_error.with_context("Step 11: Distribution confirmation")
11760 })?;
11761
11762 tracing::info!(
11763 "🎉 Asset distribution completed successfully for asset: {} with transaction: {}",
11764 asset_uuid,
11765 txid
11766 );
11767
11768 Ok(())
11769 }
11770
11771 /// Validates the asset UUID format
11772 ///
11773 /// Ensures the asset UUID follows the standard UUID format (8-4-4-4-12 hexadecimal digits)
11774 ///
11775 /// # Arguments
11776 /// * `asset_uuid` - The asset UUID string to validate
11777 ///
11778 /// # Returns
11779 /// Returns `Ok(())` if valid, or an error describing the validation failure
11780 ///
11781 /// # Errors
11782 /// - Empty or whitespace-only UUID
11783 /// - Invalid UUID format (not matching standard UUID pattern)
11784 /// - UUID contains invalid characters
11785 fn validate_asset_uuid(asset_uuid: &str) -> Result<(), String> {
11786 if asset_uuid.trim().is_empty() {
11787 return Err("Asset UUID cannot be empty".to_string());
11788 }
11789
11790 // Basic UUID format validation (8-4-4-4-12 pattern)
11791 // Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
11792 let parts: Vec<&str> = asset_uuid.split('-').collect();
11793 if parts.len() != 5 {
11794 return Err(format!(
11795 "Asset UUID '{asset_uuid}' does not have 5 parts separated by hyphens"
11796 ));
11797 }
11798
11799 // Check each part has the correct length and contains only hex characters
11800 let expected_lengths = [8, 4, 4, 4, 12];
11801 for (i, (part, &expected_len)) in parts.iter().zip(expected_lengths.iter()).enumerate() {
11802 if part.len() != expected_len {
11803 return Err(format!(
11804 "Asset UUID part {} has length {} but expected {}",
11805 i + 1,
11806 part.len(),
11807 expected_len
11808 ));
11809 }
11810
11811 // Check if all characters are valid hexadecimal
11812 if !part.chars().all(|c| c.is_ascii_hexdigit()) {
11813 return Err(format!(
11814 "Asset UUID part {} contains non-hexadecimal characters: '{}'",
11815 i + 1,
11816 part
11817 ));
11818 }
11819 }
11820
11821 tracing::debug!("Asset UUID validation passed: {}", asset_uuid);
11822 Ok(())
11823 }
11824
11825 /// Validates the assignments data structure
11826 ///
11827 /// Ensures assignments vector is not empty and each assignment has valid data
11828 ///
11829 /// # Arguments
11830 /// * `assignments` - Vector of assignments to validate
11831 ///
11832 /// # Returns
11833 /// Returns `Ok(())` if valid, or an error describing the validation failure
11834 ///
11835 /// # Errors
11836 /// - Empty assignments vector
11837 /// - Assignment with empty `user_id`
11838 /// - Assignment with empty address
11839 /// - Assignment with non-positive amount
11840 /// - Assignment with invalid address format
11841 #[allow(clippy::cognitive_complexity)]
11842 fn validate_assignments(assignments: &[AssetDistributionAssignment]) -> Result<(), String> {
11843 tracing::debug!("Validating {} assignments", assignments.len());
11844
11845 if assignments.is_empty() {
11846 tracing::error!("Assignments validation failed: empty assignments vector");
11847 return Err("Assignments vector cannot be empty".to_string());
11848 }
11849
11850 let mut total_amount = 0.0;
11851 let mut unique_addresses = std::collections::HashSet::new();
11852 let mut unique_users = std::collections::HashSet::new();
11853
11854 for (index, assignment) in assignments.iter().enumerate() {
11855 tracing::trace!(
11856 "Validating assignment {}: user_id={}, address={}, amount={}",
11857 index,
11858 assignment.user_id,
11859 assignment.address,
11860 assignment.amount
11861 );
11862
11863 // Validate user_id
11864 if assignment.user_id.trim().is_empty() {
11865 tracing::error!("Assignment {} validation failed: empty user_id", index);
11866 return Err(format!("Assignment {index} has empty user_id"));
11867 }
11868
11869 // Validate address
11870 if assignment.address.trim().is_empty() {
11871 tracing::error!("Assignment {} validation failed: empty address", index);
11872 return Err(format!("Assignment {index} has empty address"));
11873 }
11874
11875 // Basic address format validation (should start with appropriate prefix for Liquid)
11876 if !assignment.address.starts_with("lq")
11877 && !assignment.address.starts_with("vj")
11878 && !assignment.address.starts_with("VJ")
11879 && !assignment.address.starts_with("VT")
11880 {
11881 tracing::error!(
11882 "Assignment {} validation failed: invalid address format '{}' (should start with 'lq', 'vj', 'VJ', or 'VT')",
11883 index, assignment.address
11884 );
11885 return Err(format!(
11886 "Assignment {} has invalid address format: '{}' (should start with 'lq', 'vj', 'VJ', or 'VT')",
11887 index, assignment.address
11888 ));
11889 }
11890
11891 // Validate amount
11892 if assignment.amount <= 0.0 {
11893 tracing::error!(
11894 "Assignment {} validation failed: non-positive amount {}",
11895 index,
11896 assignment.amount
11897 );
11898 return Err(format!(
11899 "Assignment {} has non-positive amount: {}",
11900 index, assignment.amount
11901 ));
11902 }
11903
11904 // Check for reasonable amount limits (prevent overflow issues)
11905 if assignment.amount > 21_000_000.0 {
11906 tracing::error!(
11907 "Assignment {} validation failed: unreasonably large amount {} (max: 21,000,000)",
11908 index, assignment.amount
11909 );
11910 return Err(format!(
11911 "Assignment {} has unreasonably large amount: {} (max: 21,000,000)",
11912 index, assignment.amount
11913 ));
11914 }
11915
11916 // Check for precision issues (more than 8 decimal places)
11917 let amount_str = format!("{:.8}", assignment.amount);
11918 if amount_str.len() > 20 {
11919 // Reasonable length check
11920 tracing::warn!(
11921 "Assignment {} has high precision amount: {} - may cause precision issues",
11922 index,
11923 assignment.amount
11924 );
11925 }
11926
11927 // Track duplicates for warnings
11928 if !unique_addresses.insert(&assignment.address) {
11929 tracing::warn!(
11930 "Assignment {} uses duplicate address: {} (this may be intentional)",
11931 index,
11932 assignment.address
11933 );
11934 }
11935
11936 if !unique_users.insert(&assignment.user_id) {
11937 tracing::warn!(
11938 "Assignment {} uses duplicate user_id: {} (this may be intentional)",
11939 index,
11940 assignment.user_id
11941 );
11942 }
11943
11944 total_amount += assignment.amount;
11945 }
11946
11947 tracing::debug!(
11948 "Assignments validation passed - {} assignments, total amount: {}, unique addresses: {}, unique users: {}",
11949 assignments.len(),
11950 total_amount,
11951 unique_addresses.len(),
11952 unique_users.len()
11953 );
11954
11955 if total_amount > 100_000_000.0 {
11956 tracing::warn!(
11957 "Total distribution amount is very large: {} - ensure this is intentional",
11958 total_amount
11959 );
11960 }
11961
11962 Ok(())
11963 }
11964
11965 /// Validates `ElementsRpc` connection availability
11966 ///
11967 /// Attempts to connect to the Elements node and verify basic functionality
11968 ///
11969 /// # Arguments
11970 /// * `node_rpc` - `ElementsRpc` client to validate
11971 ///
11972 /// # Returns
11973 /// Returns `Ok(())` if connection is valid, or an error describing the failure
11974 ///
11975 /// # Errors
11976 /// - Cannot connect to Elements node
11977 /// - Node is not synchronized
11978 /// - Node version is incompatible
11979 /// - RPC authentication fails
11980 #[allow(clippy::cognitive_complexity)]
11981 async fn validate_elements_rpc_connection(&self, node_rpc: &ElementsRpc) -> Result<(), String> {
11982 tracing::debug!("Validating Elements RPC connection");
11983
11984 // Test basic connectivity by getting network info
11985 tracing::trace!("Testing Elements RPC connectivity with getnetworkinfo");
11986 let network_info = node_rpc.get_network_info().await.map_err(|e| {
11987 tracing::error!("Failed to get network info from Elements node: {}", e);
11988 format!("Failed to get network info: {e}")
11989 })?;
11990
11991 tracing::debug!(
11992 "Network info retrieved - version: {}, connections: {}, network_active: {}",
11993 network_info.version,
11994 network_info.connections,
11995 network_info.networkactive
11996 );
11997
11998 // Check if network is active
11999 if !network_info.networkactive {
12000 tracing::error!("Elements node network is not active");
12001 return Err("Elements node network is not active".to_string());
12002 }
12003
12004 // Verify we have active connections (for non-regtest environments)
12005 if network_info.connections == 0 {
12006 tracing::warn!("Elements node has no peer connections (may be regtest environment)");
12007 } else {
12008 tracing::debug!(
12009 "Elements node has {} peer connections",
12010 network_info.connections
12011 );
12012 }
12013
12014 // Test blockchain info to ensure node is operational
12015 tracing::trace!("Testing Elements RPC with getblockchaininfo");
12016 let blockchain_info = node_rpc.get_blockchain_info().await.map_err(|e| {
12017 tracing::error!("Failed to get blockchain info from Elements node: {}", e);
12018 format!("Failed to get blockchain info: {e}")
12019 })?;
12020
12021 let sync_progress = blockchain_info.verificationprogress.unwrap_or(1.0) * 100.0;
12022 tracing::debug!(
12023 "Blockchain info retrieved - chain: {}, blocks: {}, sync_progress: {:.2}%",
12024 blockchain_info.chain,
12025 blockchain_info.blocks,
12026 sync_progress
12027 );
12028
12029 // Check if node is still in initial block download
12030 if blockchain_info.initialblockdownload.unwrap_or(false) {
12031 tracing::error!(
12032 "Elements node is still in initial block download (sync progress: {:.2}%)",
12033 sync_progress
12034 );
12035 return Err(format!(
12036 "Elements node is still in initial block download (sync progress: {sync_progress:.2}%)"
12037 ));
12038 }
12039
12040 // Check sync progress
12041 if blockchain_info.verificationprogress.unwrap_or(1.0) < 0.99 {
12042 tracing::warn!(
12043 "Elements node may not be fully synced (sync progress: {:.2}%)",
12044 sync_progress
12045 );
12046 }
12047
12048 // Check for warnings
12049 if !network_info.warnings.is_empty() {
12050 tracing::warn!("Elements node network warnings: {}", network_info.warnings);
12051 }
12052
12053 if let Some(warnings) = &blockchain_info.warnings {
12054 if !warnings.is_empty() {
12055 tracing::warn!("Elements node blockchain warnings: {}", warnings);
12056 }
12057 }
12058
12059 tracing::debug!(
12060 "ElementsRpc connection validation passed - chain: {}, blocks: {}, connections: {}, sync: {:.2}%",
12061 blockchain_info.chain,
12062 blockchain_info.blocks,
12063 network_info.connections,
12064 sync_progress
12065 );
12066
12067 Ok(())
12068 }
12069
12070 /// Validates signer interface availability
12071 ///
12072 /// Tests the signer interface with a dummy transaction to ensure it's functional
12073 ///
12074 /// # Arguments
12075 /// * `signer` - Signer implementation to validate
12076 ///
12077 /// # Returns
12078 /// Returns `Ok(())` if signer is functional, or an error describing the failure
12079 ///
12080 /// # Errors
12081 /// - Signer interface is not responsive
12082 /// - Signer fails basic functionality test
12083 #[allow(clippy::cognitive_complexity)]
12084 async fn validate_signer_interface(&self, signer: &dyn Signer) -> Result<(), String> {
12085 tracing::debug!("Validating signer interface");
12086
12087 // Test signer with a minimal dummy transaction hex
12088 // This is a minimal Elements transaction structure that should parse but not be valid for signing
12089 let dummy_tx = "0200000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
12090
12091 tracing::trace!("Testing signer interface with dummy transaction");
12092
12093 // Attempt to sign the dummy transaction - we expect this to fail with a specific error
12094 // but the signer should be responsive and not panic
12095 let validation_start = std::time::Instant::now();
12096 match signer.sign_transaction(dummy_tx).await {
12097 Ok(signed_tx) => {
12098 // Unexpected success with dummy transaction - this might indicate an issue
12099 tracing::warn!(
12100 "Signer unexpectedly succeeded with dummy transaction (returned: {} chars)",
12101 signed_tx.len()
12102 );
12103 tracing::debug!("Signer validation passed despite unexpected success");
12104 }
12105 Err(SignerError::InvalidTransaction(msg)) => {
12106 // Expected error - signer is working and correctly identified invalid transaction
12107 tracing::debug!(
12108 "Signer interface validation passed - correctly rejected dummy transaction: {}",
12109 msg
12110 );
12111 }
12112 Err(SignerError::HexParse(msg)) => {
12113 // Also acceptable - signer is working and correctly identified parsing issue
12114 tracing::debug!(
12115 "Signer interface validation passed - correctly identified hex parsing issue: {}",
12116 msg
12117 );
12118 }
12119 Err(SignerError::Lwk(msg)) => {
12120 // LWK-specific errors might be acceptable depending on the message
12121 if msg.contains("invalid") || msg.contains("parse") || msg.contains("decode") {
12122 tracing::debug!(
12123 "Signer interface validation passed - LWK correctly identified invalid transaction: {}",
12124 msg
12125 );
12126 } else {
12127 tracing::error!("Signer interface test failed with LWK error: {}", msg);
12128 return Err(format!(
12129 "Signer interface test failed with LWK error: {msg}"
12130 ));
12131 }
12132 }
12133 Err(e) => {
12134 // Other errors might indicate signer interface issues
12135 tracing::error!("Signer interface test failed: {}", e);
12136 return Err(format!("Signer interface test failed: {e}"));
12137 }
12138 }
12139
12140 let validation_duration = validation_start.elapsed();
12141 tracing::debug!(
12142 "Signer interface validation completed in {:?}",
12143 validation_duration
12144 );
12145
12146 // Warn if signer is very slow (might indicate performance issues)
12147 if validation_duration > std::time::Duration::from_secs(5) {
12148 tracing::warn!(
12149 "Signer interface validation took {:?} - this may indicate performance issues",
12150 validation_duration
12151 );
12152 }
12153
12154 Ok(())
12155 }
12156}
12157
12158fn get_amp_api_base_url() -> Result<Url, Error> {
12159 let url_str = env::var("AMP_API_BASE_URL")
12160 .unwrap_or_else(|_| "https://amp-test.blockstream.com/api".to_string());
12161 Url::parse(&url_str).map_err(Error::from)
12162}
12163
12164/// Creates a token strategy based on automatic environment detection
12165///
12166/// This function detects the current environment and creates the appropriate strategy:
12167/// - Mock strategy for mock environments (isolated, no persistence)
12168/// - Live strategy for live environments (full token management)
12169///
12170/// # Arguments
12171/// * `mock_token` - Optional token to use for mock environments
12172///
12173/// # Errors
12174/// Returns an error if strategy creation fails
12175pub async fn create_auto_token_strategy(
12176 mock_token: Option<String>,
12177) -> Result<Box<dyn TokenStrategy>, Error> {
12178 TokenEnvironment::create_auto_strategy(mock_token).await
12179}
12180
12181/// Creates a mock token strategy with the specified token
12182///
12183/// # Arguments
12184/// * `token` - The mock token to use
12185#[must_use]
12186pub fn create_mock_token_strategy(token: String) -> Box<dyn TokenStrategy> {
12187 Box::new(MockTokenStrategy::new(token))
12188}
12189
12190/// Creates a live token strategy with default configuration
12191///
12192/// # Errors
12193/// Returns an error if the `TokenManager` cannot be initialized
12194pub async fn create_live_token_strategy() -> Result<Box<dyn TokenStrategy>, Error> {
12195 let strategy = LiveTokenStrategy::new().await?;
12196 Ok(Box::new(strategy))
12197}
12198
12199/// Creates a token strategy for the specified environment
12200///
12201/// # Arguments
12202/// * `environment` - The target environment
12203/// * `mock_token` - Optional token to use for mock environments
12204///
12205/// # Errors
12206/// Returns an error if strategy creation fails
12207pub async fn create_token_strategy_for_environment(
12208 environment: TokenEnvironment,
12209 mock_token: Option<String>,
12210) -> Result<Box<dyn TokenStrategy>, Error> {
12211 environment.create_strategy(mock_token).await
12212}
12213
12214#[cfg(test)]
12215mod tests {
12216 use super::*;
12217 use crate::signer::LwkSoftwareSigner;
12218 use tokio;
12219
12220 #[tokio::test]
12221 async fn test_mock_token_strategy_basic_functionality() {
12222 let mock_token = "mock_token_12_345".to_string();
12223 let strategy = MockTokenStrategy::new(mock_token.clone());
12224
12225 // Test get_token returns the mock token
12226 let result = strategy.get_token().await;
12227 assert!(result.is_ok());
12228 assert_eq!(result.unwrap(), mock_token);
12229
12230 // Test strategy type identification
12231 assert_eq!(strategy.strategy_type(), "mock");
12232
12233 // Test persistence is disabled
12234 assert!(!strategy.should_persist());
12235
12236 // Test clear_token is a no-op (should not fail)
12237 let clear_result = strategy.clear_token().await;
12238 assert!(clear_result.is_ok());
12239
12240 // Verify token is still available after clear (since it's a no-op for mock)
12241 let token_after_clear = strategy.get_token().await;
12242 assert!(token_after_clear.is_ok());
12243 assert_eq!(token_after_clear.unwrap(), mock_token);
12244 }
12245
12246 #[tokio::test]
12247 async fn test_mock_token_strategy_isolation() {
12248 let token1 = "token_instance_1".to_string();
12249 let token2 = "token_instance_2".to_string();
12250
12251 let strategy1 = MockTokenStrategy::new(token1.clone());
12252 let strategy2 = MockTokenStrategy::new(token2.clone());
12253
12254 // Test that different instances are isolated
12255 let result1 = strategy1.get_token().await.unwrap();
12256 let result2 = strategy2.get_token().await.unwrap();
12257
12258 assert_eq!(result1, token1);
12259 assert_eq!(result2, token2);
12260 assert_ne!(result1, result2);
12261
12262 // Test that operations on one don't affect the other
12263 let _ = strategy1.clear_token().await;
12264 let result2_after_clear = strategy2.get_token().await.unwrap();
12265 assert_eq!(result2_after_clear, token2);
12266 }
12267
12268 #[tokio::test]
12269 async fn test_live_token_strategy_creation() {
12270 // Test creating a live strategy with global instance
12271 let strategy_result = LiveTokenStrategy::new().await;
12272 assert!(strategy_result.is_ok());
12273
12274 let strategy = strategy_result.unwrap();
12275 assert_eq!(strategy.strategy_type(), "live");
12276 assert!(strategy.should_persist());
12277 }
12278
12279 #[tokio::test]
12280 async fn test_live_token_strategy_with_custom_manager() {
12281 // Create a custom token manager for testing
12282 let config = RetryConfig::for_tests();
12283 let base_url = Url::parse("http://localhost:8080").unwrap();
12284 let mock_token = "test_live_token".to_string();
12285
12286 let token_manager =
12287 Arc::new(TokenManager::with_mock_token(config, base_url, mock_token.clone()).unwrap());
12288
12289 let strategy = LiveTokenStrategy::with_token_manager(token_manager);
12290
12291 // Test strategy properties
12292 assert_eq!(strategy.strategy_type(), "live");
12293 assert!(strategy.should_persist());
12294
12295 // Test token retrieval
12296 let token_result = strategy.get_token().await;
12297 assert!(token_result.is_ok());
12298 assert_eq!(token_result.unwrap(), mock_token);
12299 }
12300
12301 #[tokio::test]
12302 async fn test_live_token_strategy_clear_token() {
12303 // Create a live strategy with a mock token manager
12304 let config = RetryConfig::for_tests();
12305 let base_url = Url::parse("http://localhost:8080").unwrap();
12306 let mock_token = "test_clear_token".to_string();
12307
12308 let token_manager =
12309 Arc::new(TokenManager::with_mock_token(config, base_url, mock_token.clone()).unwrap());
12310
12311 let strategy = LiveTokenStrategy::with_token_manager(token_manager);
12312
12313 // Verify token is available initially
12314 let initial_token = strategy.get_token().await;
12315 assert!(initial_token.is_ok());
12316 assert_eq!(initial_token.unwrap(), mock_token);
12317
12318 // Clear the token
12319 let clear_result = strategy.clear_token().await;
12320 assert!(clear_result.is_ok());
12321
12322 // Note: After clearing, the TokenManager would try to obtain a new token
12323 // In a real scenario, this would fail without proper credentials
12324 // But our mock token manager will still return the same token
12325 }
12326
12327 #[tokio::test]
12328 async fn test_strategy_type_identification() {
12329 let mock_strategy = MockTokenStrategy::new("test_token".to_string());
12330 let live_strategy = LiveTokenStrategy::new().await.unwrap();
12331
12332 // Test that we can identify strategy types for debugging
12333 assert_eq!(mock_strategy.strategy_type(), "mock");
12334 assert_eq!(live_strategy.strategy_type(), "live");
12335
12336 // Test persistence settings
12337 assert!(!mock_strategy.should_persist());
12338 assert!(live_strategy.should_persist());
12339 }
12340
12341 #[tokio::test]
12342 async fn test_strategy_debug_formatting() {
12343 let mock_strategy = MockTokenStrategy::new("debug_test_token".to_string());
12344 let debug_output = format!("{mock_strategy:?}");
12345
12346 // Verify debug output contains expected information
12347 assert!(debug_output.contains("MockTokenStrategy"));
12348 assert!(debug_output.contains("debug_test_token"));
12349 }
12350
12351 // Environment Detection Tests
12352
12353 #[test]
12354 fn test_token_environment_detect_live_via_amp_tests() {
12355 // Set up environment for live test detection
12356 env::set_var("AMP_TESTS", "live");
12357 env::set_var("AMP_USERNAME", "real_user");
12358 env::set_var("AMP_PASSWORD", "real_pass");
12359 env::remove_var("AMP_API_BASE_URL");
12360
12361 let environment = TokenEnvironment::detect();
12362 assert_eq!(environment, TokenEnvironment::Live);
12363
12364 // Clean up
12365 env::remove_var("AMP_TESTS");
12366 env::remove_var("AMP_USERNAME");
12367 env::remove_var("AMP_PASSWORD");
12368 }
12369
12370 #[test]
12371 fn test_token_environment_detect_mock_via_credentials() {
12372 // Set up environment for mock detection via username
12373 env::remove_var("AMP_TESTS");
12374 env::set_var("AMP_USERNAME", "mock_user");
12375 env::set_var("AMP_PASSWORD", "real_pass");
12376 env::remove_var("AMP_API_BASE_URL");
12377
12378 let environment = TokenEnvironment::detect();
12379 assert_eq!(environment, TokenEnvironment::Mock);
12380
12381 // Test mock detection via password
12382 env::set_var("AMP_USERNAME", "real_user");
12383 env::set_var("AMP_PASSWORD", "mock_pass");
12384
12385 let environment = TokenEnvironment::detect();
12386 assert_eq!(environment, TokenEnvironment::Mock);
12387
12388 // Clean up
12389 env::remove_var("AMP_USERNAME");
12390 env::remove_var("AMP_PASSWORD");
12391 }
12392
12393 #[test]
12394 fn test_token_environment_detect_mock_via_base_url() {
12395 // Set up environment for mock detection via localhost URL
12396 env::remove_var("AMP_TESTS");
12397 env::set_var("AMP_USERNAME", "real_user");
12398 env::set_var("AMP_PASSWORD", "real_pass");
12399 env::set_var("AMP_API_BASE_URL", "http://localhost:8080/api");
12400
12401 let environment = TokenEnvironment::detect();
12402 assert_eq!(environment, TokenEnvironment::Mock);
12403
12404 // Test with 127.0.0.1
12405 env::set_var("AMP_API_BASE_URL", "http://127.0.0.1:3000/api");
12406 let environment = TokenEnvironment::detect();
12407 assert_eq!(environment, TokenEnvironment::Mock);
12408
12409 // Test with mock in URL
12410 env::set_var("AMP_API_BASE_URL", "http://mock-server.example.com/api");
12411 let environment = TokenEnvironment::detect();
12412 assert_eq!(environment, TokenEnvironment::Mock);
12413
12414 // Clean up
12415 env::remove_var("AMP_USERNAME");
12416 env::remove_var("AMP_PASSWORD");
12417 env::remove_var("AMP_API_BASE_URL");
12418 }
12419
12420 #[test]
12421 fn test_token_environment_detect_live_via_real_credentials() {
12422 // Set up environment for live detection via real credentials
12423 env::remove_var("AMP_TESTS");
12424 env::set_var("AMP_USERNAME", "real_user");
12425 env::set_var("AMP_PASSWORD", "real_pass");
12426 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
12427
12428 let environment = TokenEnvironment::detect();
12429 assert_eq!(environment, TokenEnvironment::Live);
12430
12431 // Clean up
12432 env::remove_var("AMP_USERNAME");
12433 env::remove_var("AMP_PASSWORD");
12434 env::remove_var("AMP_API_BASE_URL");
12435 }
12436
12437 #[test]
12438 fn test_token_environment_detect_mock_fallback() {
12439 // Set up environment with no credentials (fallback to mock)
12440 env::remove_var("AMP_TESTS");
12441 env::remove_var("AMP_USERNAME");
12442 env::remove_var("AMP_PASSWORD");
12443 env::remove_var("AMP_API_BASE_URL");
12444
12445 let environment = TokenEnvironment::detect();
12446 assert_eq!(environment, TokenEnvironment::Mock);
12447 }
12448
12449 #[test]
12450 fn test_has_mock_credentials() {
12451 // Test mock username detection
12452 assert!(TokenEnvironment::has_mock_credentials(
12453 "mock_user",
12454 "real_pass",
12455 ""
12456 ));
12457 assert!(TokenEnvironment::has_mock_credentials(
12458 "Mock_User",
12459 "real_pass",
12460 ""
12461 ));
12462 assert!(TokenEnvironment::has_mock_credentials(
12463 "user_mock",
12464 "real_pass",
12465 ""
12466 ));
12467
12468 // Test mock password detection
12469 assert!(TokenEnvironment::has_mock_credentials(
12470 "real_user",
12471 "mock_pass",
12472 ""
12473 ));
12474 assert!(TokenEnvironment::has_mock_credentials(
12475 "real_user",
12476 "Mock_Pass",
12477 ""
12478 ));
12479 assert!(TokenEnvironment::has_mock_credentials(
12480 "real_user",
12481 "pass_mock",
12482 ""
12483 ));
12484
12485 // Test mock URL detection
12486 assert!(TokenEnvironment::has_mock_credentials(
12487 "real_user",
12488 "real_pass",
12489 "http://localhost:8080"
12490 ));
12491 assert!(TokenEnvironment::has_mock_credentials(
12492 "real_user",
12493 "real_pass",
12494 "http://127.0.0.1:3000"
12495 ));
12496 assert!(TokenEnvironment::has_mock_credentials(
12497 "real_user",
12498 "real_pass",
12499 "http://mock-server.com"
12500 ));
12501 assert!(TokenEnvironment::has_mock_credentials(
12502 "real_user",
12503 "real_pass",
12504 "http://Mock-Server.com"
12505 ));
12506
12507 // Test non-mock credentials
12508 assert!(!TokenEnvironment::has_mock_credentials(
12509 "real_user",
12510 "real_pass",
12511 "https://amp-test.blockstream.com"
12512 ));
12513 assert!(!TokenEnvironment::has_mock_credentials("", "", ""));
12514 }
12515
12516 #[test]
12517 fn test_token_environment_should_persist_tokens() {
12518 assert!(!TokenEnvironment::Mock.should_persist_tokens());
12519 assert!(TokenEnvironment::Live.should_persist_tokens());
12520
12521 // Auto should delegate to detect()
12522 env::set_var("AMP_TESTS", "live");
12523 assert!(TokenEnvironment::Auto.should_persist_tokens());
12524
12525 env::set_var("AMP_USERNAME", "mock_user");
12526 env::set_var("AMP_PASSWORD", "some_password");
12527 env::remove_var("AMP_TESTS");
12528 env::remove_var("AMP_API_BASE_URL");
12529 assert!(!TokenEnvironment::Auto.should_persist_tokens());
12530
12531 // Clean up
12532 env::remove_var("AMP_USERNAME");
12533 env::remove_var("AMP_PASSWORD");
12534 }
12535
12536 #[test]
12537 fn test_token_environment_is_mock_and_is_live() {
12538 assert!(TokenEnvironment::Mock.is_mock());
12539 assert!(!TokenEnvironment::Mock.is_live());
12540
12541 assert!(!TokenEnvironment::Live.is_mock());
12542 assert!(TokenEnvironment::Live.is_live());
12543
12544 // Auto should delegate to detect()
12545 env::set_var("AMP_USERNAME", "mock_user");
12546 env::set_var("AMP_PASSWORD", "some_password");
12547 env::remove_var("AMP_TESTS");
12548 env::remove_var("AMP_API_BASE_URL");
12549 assert!(TokenEnvironment::Auto.is_mock());
12550 assert!(!TokenEnvironment::Auto.is_live());
12551
12552 env::set_var("AMP_TESTS", "live");
12553 assert!(!TokenEnvironment::Auto.is_mock());
12554 assert!(TokenEnvironment::Auto.is_live());
12555
12556 // Clean up
12557 env::remove_var("AMP_USERNAME");
12558 env::remove_var("AMP_PASSWORD");
12559 env::remove_var("AMP_TESTS");
12560 }
12561
12562 #[tokio::test]
12563 async fn test_token_environment_create_strategy_mock() {
12564 let mock_token = "test_mock_token".to_string();
12565 let strategy = TokenEnvironment::Mock
12566 .create_strategy(Some(mock_token.clone()))
12567 .await
12568 .unwrap();
12569
12570 assert_eq!(strategy.strategy_type(), "mock");
12571 assert!(!strategy.should_persist());
12572
12573 let token = strategy.get_token().await.unwrap();
12574 assert_eq!(token, mock_token);
12575 }
12576
12577 #[tokio::test]
12578 async fn test_token_environment_create_strategy_live() {
12579 let strategy = TokenEnvironment::Live.create_strategy(None).await.unwrap();
12580
12581 assert_eq!(strategy.strategy_type(), "live");
12582 assert!(strategy.should_persist());
12583 }
12584
12585 #[tokio::test]
12586 async fn test_token_environment_create_auto_strategy() {
12587 // Test with mock environment - need both username and password for proper detection
12588 env::set_var("AMP_USERNAME", "mock_user");
12589 env::set_var("AMP_PASSWORD", "some_password");
12590 env::remove_var("AMP_TESTS");
12591 env::remove_var("AMP_API_BASE_URL");
12592
12593 let mock_token = "auto_mock_token".to_string();
12594 let strategy = TokenEnvironment::create_auto_strategy(Some(mock_token.clone()))
12595 .await
12596 .unwrap();
12597
12598 assert_eq!(strategy.strategy_type(), "mock");
12599 let token = strategy.get_token().await.unwrap();
12600 assert_eq!(token, mock_token);
12601
12602 // Clean up
12603 env::remove_var("AMP_USERNAME");
12604 env::remove_var("AMP_PASSWORD");
12605 }
12606
12607 #[tokio::test]
12608 async fn test_mock_token_strategy_factory_methods() {
12609 // Test with_default_token
12610 let strategy = MockTokenStrategy::with_default_token();
12611 assert_eq!(strategy.strategy_type(), "mock");
12612 let token = strategy.get_token().await.unwrap();
12613 assert_eq!(token, "mock_token_default");
12614
12615 // Test for_test
12616 let strategy = MockTokenStrategy::for_test("my_test");
12617 let token = strategy.get_token().await.unwrap();
12618 assert_eq!(token, "mock_token_my_test");
12619 }
12620
12621 #[tokio::test]
12622 async fn test_live_token_strategy_factory_methods() {
12623 // Test for_testing
12624 let strategy = LiveTokenStrategy::for_testing().await.unwrap();
12625 assert_eq!(strategy.strategy_type(), "live");
12626 assert!(strategy.should_persist());
12627 }
12628
12629 #[tokio::test]
12630 async fn test_standalone_factory_functions() {
12631 // Test create_mock_token_strategy
12632 let mock_token = "standalone_mock".to_string();
12633 let strategy = create_mock_token_strategy(mock_token.clone());
12634 assert_eq!(strategy.strategy_type(), "mock");
12635 let token = strategy.get_token().await.unwrap();
12636 assert_eq!(token, mock_token);
12637
12638 // Test create_live_token_strategy
12639 let strategy = create_live_token_strategy().await.unwrap();
12640 assert_eq!(strategy.strategy_type(), "live");
12641
12642 // Test create_auto_token_strategy with mock environment
12643 env::set_var("AMP_USERNAME", "mock_user");
12644 env::set_var("AMP_PASSWORD", "some_password");
12645 env::remove_var("AMP_TESTS");
12646 env::remove_var("AMP_API_BASE_URL");
12647
12648 let auto_mock_token = "auto_standalone_mock".to_string();
12649 let strategy = create_auto_token_strategy(Some(auto_mock_token.clone()))
12650 .await
12651 .unwrap();
12652 assert_eq!(strategy.strategy_type(), "mock");
12653 let token = strategy.get_token().await.unwrap();
12654 assert_eq!(token, auto_mock_token);
12655
12656 // Test create_token_strategy_for_environment
12657 let env_mock_token = "env_mock".to_string();
12658 let strategy = create_token_strategy_for_environment(
12659 TokenEnvironment::Mock,
12660 Some(env_mock_token.clone()),
12661 )
12662 .await
12663 .unwrap();
12664 assert_eq!(strategy.strategy_type(), "mock");
12665 let token = strategy.get_token().await.unwrap();
12666 assert_eq!(token, env_mock_token);
12667
12668 // Clean up
12669 env::remove_var("AMP_USERNAME");
12670 env::remove_var("AMP_PASSWORD");
12671 }
12672
12673 #[test]
12674 fn test_environment_detection_with_various_credential_combinations() {
12675 // Test case 1: AMP_TESTS=live overrides everything
12676 env::set_var("AMP_TESTS", "live");
12677 env::set_var("AMP_USERNAME", "mock_user");
12678 env::set_var("AMP_PASSWORD", "mock_pass");
12679 env::set_var("AMP_API_BASE_URL", "http://localhost:8080");
12680 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Live);
12681
12682 // Test case 2: Mock username with real password and URL
12683 env::remove_var("AMP_TESTS");
12684 env::set_var("AMP_USERNAME", "mock_user");
12685 env::set_var("AMP_PASSWORD", "real_password");
12686 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
12687 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
12688
12689 // Test case 3: Real username with mock password
12690 env::set_var("AMP_USERNAME", "real_user");
12691 env::set_var("AMP_PASSWORD", "mock_password");
12692 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
12693 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
12694
12695 // Test case 4: Real credentials with localhost URL
12696 env::set_var("AMP_USERNAME", "real_user");
12697 env::set_var("AMP_PASSWORD", "real_password");
12698 env::set_var("AMP_API_BASE_URL", "http://localhost:3000/api");
12699 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
12700
12701 // Test case 5: All real credentials
12702 env::set_var("AMP_USERNAME", "real_user");
12703 env::set_var("AMP_PASSWORD", "real_password");
12704 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
12705 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Live);
12706
12707 // Test case 6: Empty credentials
12708 env::remove_var("AMP_USERNAME");
12709 env::remove_var("AMP_PASSWORD");
12710 env::remove_var("AMP_API_BASE_URL");
12711 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
12712
12713 // Test case 7: Only username set
12714 env::set_var("AMP_USERNAME", "real_user");
12715 env::remove_var("AMP_PASSWORD");
12716 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
12717
12718 // Test case 8: Only password set
12719 env::remove_var("AMP_USERNAME");
12720 env::set_var("AMP_PASSWORD", "real_password");
12721 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
12722
12723 // Clean up all environment variables
12724 env::remove_var("AMP_TESTS");
12725 env::remove_var("AMP_USERNAME");
12726 env::remove_var("AMP_PASSWORD");
12727 env::remove_var("AMP_API_BASE_URL");
12728 }
12729
12730 #[tokio::test]
12731 async fn test_distribute_asset_input_validation() {
12732 // Create a mock client for testing
12733 let client = ApiClient::with_mock_token(
12734 reqwest::Url::parse("http://localhost:8080/api").unwrap(),
12735 "test_token".to_string(),
12736 )
12737 .unwrap();
12738
12739 // Test invalid asset UUID
12740 let assignments = vec![AssetDistributionAssignment {
12741 user_id: "user123".to_string(),
12742 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
12743 amount: 100.0,
12744 }];
12745
12746 // Create a mock ElementsRpc (this will fail connection validation, but that's expected)
12747 let elements_rpc = ElementsRpc::new(
12748 "http://localhost:18884".to_string(),
12749 "user".to_string(),
12750 "pass".to_string(),
12751 );
12752
12753 // Create a mock signer
12754 let (_, signer) = LwkSoftwareSigner::generate_new().unwrap();
12755
12756 // Test with invalid UUID format
12757 let result = client
12758 .distribute_asset(
12759 "invalid-uuid",
12760 assignments.clone(),
12761 &elements_rpc,
12762 "test_wallet",
12763 &signer,
12764 )
12765 .await;
12766
12767 assert!(result.is_err());
12768 if let Err(AmpError::Validation(msg)) = result {
12769 assert!(msg.contains("Invalid asset UUID"));
12770 } else {
12771 panic!("Expected validation error for invalid UUID");
12772 }
12773
12774 // Test with empty assignments
12775 let result = client
12776 .distribute_asset(
12777 "550e8400-e29b-41d4-a716-446655440000",
12778 vec![],
12779 &elements_rpc,
12780 "test_wallet",
12781 &signer,
12782 )
12783 .await;
12784
12785 assert!(result.is_err());
12786 if let Err(AmpError::Validation(msg)) = result {
12787 assert!(msg.contains("Invalid assignments"));
12788 } else {
12789 panic!("Expected validation error for empty assignments");
12790 }
12791 }
12792
12793 #[test]
12794 fn test_validate_asset_uuid() {
12795 let _client = ApiClient::with_mock_token(
12796 reqwest::Url::parse("http://localhost:8080/api").unwrap(),
12797 "test_token".to_string(),
12798 )
12799 .unwrap();
12800
12801 // Valid UUID
12802 assert!(ApiClient::validate_asset_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
12803
12804 // Invalid UUIDs
12805 assert!(ApiClient::validate_asset_uuid("").is_err());
12806 assert!(ApiClient::validate_asset_uuid("invalid").is_err());
12807 assert!(ApiClient::validate_asset_uuid("550e8400-e29b-41d4-a716").is_err()); // Too short
12808 assert!(
12809 ApiClient::validate_asset_uuid("550e8400-e29b-41d4-a716-446655440000-extra").is_err()
12810 ); // Too long
12811 assert!(ApiClient::validate_asset_uuid("550e8400xe29bx41d4xa716x446655440000").is_err()); // Wrong separators
12812 assert!(ApiClient::validate_asset_uuid("550e8400-e29g-41d4-a716-446655440000").is_err());
12813 // Invalid hex char
12814 }
12815
12816 #[test]
12817 fn test_validate_assignments() {
12818 let _client = ApiClient::with_mock_token(
12819 reqwest::Url::parse("http://localhost:8080/api").unwrap(),
12820 "test_token".to_string(),
12821 )
12822 .unwrap();
12823
12824 // Valid assignments
12825 let valid_assignments = vec![AssetDistributionAssignment {
12826 user_id: "user123".to_string(),
12827 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
12828 amount: 100.0,
12829 }];
12830 assert!(ApiClient::validate_assignments(&valid_assignments).is_ok());
12831
12832 // Empty assignments
12833 assert!(ApiClient::validate_assignments(&[]).is_err());
12834
12835 // Assignment with empty user_id
12836 let invalid_assignments = vec![AssetDistributionAssignment {
12837 user_id: "".to_string(),
12838 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
12839 amount: 100.0,
12840 }];
12841 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
12842
12843 // Assignment with empty address
12844 let invalid_assignments = vec![AssetDistributionAssignment {
12845 user_id: "user123".to_string(),
12846 address: "".to_string(),
12847 amount: 100.0,
12848 }];
12849 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
12850
12851 // Assignment with invalid address format
12852 let invalid_assignments = vec![AssetDistributionAssignment {
12853 user_id: "user123".to_string(),
12854 address: "invalid_address".to_string(),
12855 amount: 100.0,
12856 }];
12857 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
12858
12859 // Assignment with non-positive amount
12860 let invalid_assignments = vec![AssetDistributionAssignment {
12861 user_id: "user123".to_string(),
12862 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
12863 amount: 0.0,
12864 }];
12865 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
12866
12867 // Assignment with unreasonably large amount
12868 let invalid_assignments = vec![AssetDistributionAssignment {
12869 user_id: "user123".to_string(),
12870 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
12871 amount: 25_000_000.0,
12872 }];
12873 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
12874 }
12875
12876 #[test]
12877 fn test_enhanced_error_handling_and_logging() {
12878 // Test AmpError creation and context enhancement
12879 let api_error = AmpError::api("Distribution creation failed");
12880 let contextual_error = api_error.with_context("Step 6: Distribution creation");
12881
12882 match contextual_error {
12883 AmpError::Api(msg) => {
12884 assert!(msg.contains("Step 6: Distribution creation"));
12885 assert!(msg.contains("Distribution creation failed"));
12886 }
12887 _ => panic!("Expected Api error variant"),
12888 }
12889
12890 // Test retry instructions for different error types
12891 let rpc_error = AmpError::rpc("Connection failed");
12892 assert!(rpc_error.is_retryable());
12893 assert!(rpc_error.retry_instructions().is_some());
12894 assert!(rpc_error
12895 .retry_instructions()
12896 .unwrap()
12897 .contains("Elements node"));
12898
12899 let validation_error = AmpError::validation("Invalid UUID");
12900 assert!(!validation_error.is_retryable());
12901 assert!(validation_error.retry_instructions().is_none());
12902
12903 let timeout_error = AmpError::timeout("Confirmation timeout for txid abc123");
12904 assert!(!timeout_error.is_retryable());
12905 let instructions = timeout_error.retry_instructions();
12906 assert!(instructions.is_some());
12907 assert!(instructions.unwrap().contains("transaction ID"));
12908
12909 // Test error helper methods
12910 let signer_error =
12911 AmpError::Signer(crate::signer::SignerError::Lwk("Test error".to_string()));
12912 assert!(!signer_error.is_retryable());
12913 assert!(signer_error.retry_instructions().is_none());
12914
12915 // Test serialization error
12916 let json_error = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
12917 let serialization_error = AmpError::from(json_error);
12918 assert!(matches!(serialization_error, AmpError::Serialization(_)));
12919 assert!(!serialization_error.is_retryable());
12920 }
12921}