amp_rs/client.rs
1use std::env;
2use std::sync::Arc;
3use std::time::Duration as StdDuration;
4
5use async_trait::async_trait;
6use chrono::{Duration, Utc};
7
8use reqwest::header::AUTHORIZATION;
9use reqwest::{Client, Method, Url};
10use serde::de::DeserializeOwned;
11use thiserror::Error;
12use tokio::sync::{Mutex, OnceCell, Semaphore};
13use tokio::time::sleep;
14
15use elements::encode::Decodable;
16use secrecy::ExposeSecret;
17use secrecy::Secret;
18use std::str::FromStr;
19
20use crate::model::{
21 Activity, Asset, AssetActivityParams, AssetDistributionAssignment, AssetSummary, Assignment,
22 Balance, BroadcastResponse, CategoriesRequest, CategoryAdd, CategoryEdit, CategoryResponse,
23 ChangePasswordRequest, ChangePasswordResponse, CreateAssetAssignmentRequest, EditAssetRequest,
24 GaidBalanceEntry, IssuanceRequest, IssuanceResponse, Outpoint, Ownership, Password,
25 ReceivedByAddress, RegisterAssetResponse, TokenData, TokenInfo, TokenRequest, TokenResponse,
26 TransactionDetail, TxInput, Unspent, Utxo,
27};
28use crate::signer::{Signer, SignerError};
29
30/// Environment variables used for token environment detection
31#[derive(Debug)]
32struct EnvironmentVariables {
33 username: String,
34 password: String,
35 amp_tests: String,
36 base_url: String,
37}
38
39/// Token environment detection for automatic strategy selection
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum TokenEnvironment {
42 /// Mock environment - use isolated token management without persistence
43 Mock,
44 /// Live environment - use full token management with persistence
45 Live,
46 /// Auto-detect environment based on credentials and settings
47 Auto,
48}
49
50impl TokenEnvironment {
51 /// Detects the current token environment based on environment variables and credential patterns
52 ///
53 /// Detection logic:
54 /// 1. If `AMP_TESTS=live` is set, returns `Live`
55 /// 2. If credentials contain "mock" string, returns `Mock`
56 /// 3. If real credentials are present without live test flag, returns `Live`
57 /// 4. Fallback to `Mock` for safety
58 #[must_use]
59 pub fn detect() -> Self {
60 let env_vars = Self::read_environment_variables();
61 Self::log_detection_start(&env_vars);
62
63 if Self::is_explicit_live_environment(&env_vars.amp_tests) {
64 return Self::Live;
65 }
66
67 if Self::has_mock_credentials(&env_vars.username, &env_vars.password, &env_vars.base_url) {
68 Self::log_detection_result("mock environment via mock credentials");
69 return Self::Mock;
70 }
71
72 if Self::has_real_credentials(&env_vars.username, &env_vars.password) {
73 Self::log_detection_result("live environment via real credentials");
74 return Self::Live;
75 }
76
77 Self::log_detection_result("mock environment via fallback (no credentials)");
78 Self::Mock
79 }
80
81 /// Reads environment variables needed for token environment detection
82 fn read_environment_variables() -> EnvironmentVariables {
83 EnvironmentVariables {
84 username: env::var("AMP_USERNAME").unwrap_or_default(),
85 password: env::var("AMP_PASSWORD").unwrap_or_default(),
86 amp_tests: env::var("AMP_TESTS").unwrap_or_default(),
87 base_url: env::var("AMP_API_BASE_URL").unwrap_or_default(),
88 }
89 }
90
91 /// Logs the start of environment detection with current variable values
92 fn log_detection_start(env_vars: &EnvironmentVariables) {
93 tracing::debug!(
94 "Detecting token environment - AMP_TESTS: '{}', username: '{}', base_url: '{}'",
95 env_vars.amp_tests,
96 env_vars.username,
97 env_vars.base_url
98 );
99 }
100
101 /// Checks if the environment is explicitly set to live testing
102 fn is_explicit_live_environment(amp_tests: &str) -> bool {
103 if amp_tests == "live" {
104 Self::log_detection_result("live environment via AMP_TESTS=live");
105 true
106 } else {
107 false
108 }
109 }
110
111 /// Checks if real (non-empty) credentials are present
112 const fn has_real_credentials(username: &str, password: &str) -> bool {
113 !username.is_empty() && !password.is_empty()
114 }
115
116 /// Logs the final detection result
117 fn log_detection_result(reason: &str) {
118 tracing::info!("Detected {}", reason);
119 }
120
121 /// Checks if the provided credentials indicate a mock environment
122 ///
123 /// Mock credentials are detected by:
124 /// - Username containing "mock" (case-insensitive)
125 /// - Password containing "mock" (case-insensitive)
126 /// - Base URL containing localhost, 127.0.0.1, or "mock"
127 #[must_use]
128 pub fn has_mock_credentials(username: &str, password: &str, base_url: &str) -> bool {
129 let username_lower = username.to_lowercase();
130 let password_lower = password.to_lowercase();
131 let base_url_lower = base_url.to_lowercase();
132
133 let has_mock_username = username_lower.contains("mock");
134 let has_mock_password = password_lower.contains("mock");
135 let has_mock_url = base_url_lower.contains("localhost")
136 || base_url_lower.contains("127.0.0.1")
137 || base_url_lower.contains("mock");
138
139 let is_mock = has_mock_username || has_mock_password || has_mock_url;
140
141 tracing::debug!(
142 "Mock credential check - username: {}, password: {}, url: {}, result: {}",
143 has_mock_username,
144 has_mock_password,
145 has_mock_url,
146 is_mock
147 );
148
149 is_mock
150 }
151
152 /// Creates a token strategy based on the environment type
153 ///
154 /// # Arguments
155 /// * `mock_token` - Optional mock token to use for mock environments
156 ///
157 /// # Errors
158 /// Returns an error if strategy creation fails
159 pub async fn create_strategy(
160 &self,
161 mock_token: Option<String>,
162 ) -> Result<Box<dyn TokenStrategy>, Error> {
163 match self {
164 Self::Mock => Ok(Self::create_mock_strategy(mock_token)),
165 Self::Live => Self::create_live_strategy().await,
166 Self::Auto => Self::create_auto_detected_strategy(mock_token).await,
167 }
168 }
169
170 /// Creates a mock token strategy with the provided or default token
171 fn create_mock_strategy(mock_token: Option<String>) -> Box<dyn TokenStrategy> {
172 let token = mock_token.unwrap_or_else(|| "default_mock_token".to_string());
173 tracing::debug!("Creating mock token strategy with token");
174 Box::new(MockTokenStrategy::new(token))
175 }
176
177 /// Creates a live token strategy
178 async fn create_live_strategy() -> Result<Box<dyn TokenStrategy>, Error> {
179 tracing::debug!("Creating live token strategy");
180 let strategy = LiveTokenStrategy::new().await?;
181 Ok(Box::new(strategy))
182 }
183
184 /// Creates a strategy based on auto-detected environment
185 async fn create_auto_detected_strategy(
186 mock_token: Option<String>,
187 ) -> Result<Box<dyn TokenStrategy>, Error> {
188 tracing::debug!("Auto-detecting environment for strategy creation");
189 let detected = Self::detect();
190
191 match detected {
192 Self::Mock => Ok(Self::create_auto_detected_mock_strategy(mock_token)),
193 Self::Live => Self::create_auto_detected_live_strategy().await,
194 Self::Auto => Self::handle_unexpected_auto_detection(),
195 }
196 }
197
198 /// Creates a mock strategy for auto-detected mock environment
199 fn create_auto_detected_mock_strategy(mock_token: Option<String>) -> Box<dyn TokenStrategy> {
200 let token = mock_token.unwrap_or_else(|| "default_mock_token".to_string());
201 tracing::debug!("Auto-detected mock environment, creating mock strategy");
202 Box::new(MockTokenStrategy::new(token))
203 }
204
205 /// Creates a live strategy for auto-detected live environment
206 async fn create_auto_detected_live_strategy() -> Result<Box<dyn TokenStrategy>, Error> {
207 tracing::debug!("Auto-detected live environment, creating live strategy");
208 let strategy = LiveTokenStrategy::new().await?;
209 Ok(Box::new(strategy))
210 }
211
212 /// Handles the unexpected case where `detect()` returns Auto
213 fn handle_unexpected_auto_detection() -> Result<Box<dyn TokenStrategy>, Error> {
214 tracing::error!("Unexpected Auto environment from detect()");
215 Err(Error::Token(TokenError::validation(
216 "Environment detection returned Auto, which should not happen".to_string(),
217 )))
218 }
219
220 /// Creates a token strategy with automatic environment detection
221 ///
222 /// This is a convenience method that combines environment detection with strategy creation.
223 ///
224 /// # Arguments
225 /// * `mock_token` - Optional mock token to use if mock environment is detected
226 ///
227 /// # Errors
228 /// Returns an error if strategy creation fails
229 pub async fn create_auto_strategy(
230 mock_token: Option<String>,
231 ) -> Result<Box<dyn TokenStrategy>, Error> {
232 let environment = Self::detect();
233 environment.create_strategy(mock_token).await
234 }
235
236 /// Determines if token persistence should be enabled for this environment
237 #[must_use]
238 pub fn should_persist_tokens(&self) -> bool {
239 match self {
240 Self::Mock => false,
241 Self::Live => true,
242 Self::Auto => Self::detect().should_persist_tokens(),
243 }
244 }
245
246 /// Returns true if this is a mock environment
247 #[must_use]
248 pub fn is_mock(&self) -> bool {
249 matches!(self, Self::Mock) || (matches!(self, Self::Auto) && Self::detect().is_mock())
250 }
251
252 /// Returns true if this is a live environment
253 #[must_use]
254 pub fn is_live(&self) -> bool {
255 matches!(self, Self::Live) || (matches!(self, Self::Auto) && Self::detect().is_live())
256 }
257}
258
259/// Token management strategy trait for different token handling approaches
260#[async_trait]
261pub trait TokenStrategy: Send + Sync + std::fmt::Debug {
262 /// Gets a valid authentication token
263 async fn get_token(&self) -> Result<String, Error>;
264
265 /// Clears stored token (for testing)
266 async fn clear_token(&self) -> Result<(), Error>;
267
268 /// Returns whether this strategy should persist tokens
269 fn should_persist(&self) -> bool;
270
271 /// Returns the strategy type for debugging
272 fn strategy_type(&self) -> &'static str;
273
274 /// Returns self as Any for downcasting (used internally)
275 fn as_any(&self) -> &dyn std::any::Any;
276}
277
278/// Mock token strategy for isolated testing without persistence
279#[derive(Debug, Clone)]
280pub struct MockTokenStrategy {
281 token: String,
282}
283
284impl MockTokenStrategy {
285 /// Creates a new mock token strategy with the provided token
286 #[must_use]
287 pub const fn new(token: String) -> Self {
288 Self { token }
289 }
290
291 /// Creates a mock token strategy with a default test token
292 #[must_use]
293 pub fn with_default_token() -> Self {
294 Self::new("mock_token_default".to_string())
295 }
296
297 /// Creates a mock token strategy for a specific test case
298 #[must_use]
299 pub fn for_test(test_name: &str) -> Self {
300 Self::new(format!("mock_token_{test_name}"))
301 }
302}
303
304#[async_trait]
305impl TokenStrategy for MockTokenStrategy {
306 async fn get_token(&self) -> Result<String, Error> {
307 tracing::debug!("Using mock token strategy - returning pre-set token");
308 Ok(self.token.clone())
309 }
310
311 async fn clear_token(&self) -> Result<(), Error> {
312 tracing::debug!("Mock token strategy - clear_token is a no-op");
313 Ok(())
314 }
315
316 fn should_persist(&self) -> bool {
317 false
318 }
319
320 fn strategy_type(&self) -> &'static str {
321 "mock"
322 }
323
324 fn as_any(&self) -> &dyn std::any::Any {
325 self
326 }
327}
328
329/// Live token strategy that wraps the existing `TokenManager` for full token management
330#[derive(Debug)]
331pub struct LiveTokenStrategy {
332 token_manager: Arc<TokenManager>,
333}
334
335impl LiveTokenStrategy {
336 /// Creates a new live token strategy using the global `TokenManager` instance
337 ///
338 /// # Errors
339 /// Returns an error if the `TokenManager` cannot be initialized
340 pub async fn new() -> Result<Self, Error> {
341 let token_manager = TokenManager::get_global_instance().await?;
342 Ok(Self { token_manager })
343 }
344
345 /// Creates a new live token strategy with a custom `TokenManager`
346 #[must_use]
347 pub const fn with_token_manager(token_manager: Arc<TokenManager>) -> Self {
348 Self { token_manager }
349 }
350
351 /// Creates a live token strategy with custom retry configuration
352 ///
353 /// # Errors
354 /// Returns an error if the `TokenManager` cannot be initialized
355 pub async fn with_config(config: RetryConfig) -> Result<Self, Error> {
356 let base_url = get_amp_api_base_url()?;
357 let token_manager =
358 Arc::new(TokenManager::with_config_and_base_url(config, base_url).await?);
359 Ok(Self { token_manager })
360 }
361
362 /// Creates a live token strategy optimized for testing
363 ///
364 /// # Errors
365 /// Returns an error if the `TokenManager` cannot be initialized
366 pub async fn for_testing() -> Result<Self, Error> {
367 let config = RetryConfig::for_tests();
368 Self::with_config(config).await
369 }
370
371 /// Gets current token information for debugging and monitoring
372 ///
373 /// # Errors
374 /// Returns an error if token information retrieval fails
375 pub async fn get_token_info(&self) -> Result<Option<TokenInfo>, Error> {
376 self.token_manager.get_token_info().await
377 }
378}
379
380#[async_trait]
381impl TokenStrategy for LiveTokenStrategy {
382 async fn get_token(&self) -> Result<String, Error> {
383 tracing::debug!("Using live token strategy - full token management");
384 self.token_manager.get_token().await
385 }
386
387 async fn clear_token(&self) -> Result<(), Error> {
388 self.token_manager.clear_token().await
389 }
390
391 fn should_persist(&self) -> bool {
392 true
393 }
394
395 fn strategy_type(&self) -> &'static str {
396 "live"
397 }
398
399 fn as_any(&self) -> &dyn std::any::Any {
400 self
401 }
402}
403
404#[derive(Error, Debug)]
405pub enum Error {
406 #[error("Missing {0} environment variable")]
407 MissingEnvVar(String),
408 #[error("AMP request failed: {0}")]
409 RequestFailed(String),
410 #[error("AMP request failed\n\nMethod: {method}\nEndpoint: {endpoint}\nStatus: {status}\n\nError: {error_message}")]
411 RequestFailedDetailed {
412 /// The HTTP method used (GET, POST, etc.)
413 method: String,
414 /// The full endpoint URL that was called
415 endpoint: String,
416 /// The HTTP status code
417 status: reqwest::StatusCode,
418 /// The error message or response body
419 error_message: String,
420 },
421 #[error("Failed to parse AMP response: {0}")]
422 ResponseParsingFailed(String),
423 #[error("Failed to parse AMP response: {serde_error}\n\nMethod: {method}\nEndpoint: {endpoint}\nExpected Type: {expected_type}\n\nRaw Response:\n{raw_response}")]
424 ResponseDeserializationFailed {
425 /// The HTTP method used (GET, POST, etc.)
426 method: String,
427 /// The full endpoint URL that was called
428 endpoint: String,
429 /// The expected Rust type name
430 expected_type: String,
431 /// The original serde deserialization error message
432 serde_error: String,
433 /// The complete raw response body text that failed to parse
434 raw_response: String,
435 },
436 #[error("AMP token request failed with status {status}: {error_text}")]
437 TokenRequestFailed {
438 status: reqwest::StatusCode,
439 error_text: String,
440 },
441 #[error("Failed to parse url: {0}")]
442 UrlParse(#[from] url::ParseError),
443 #[error("Reqwest error: {0}")]
444 Reqwest(#[from] reqwest::Error),
445 #[error("Invalid retry configuration: {0}")]
446 InvalidRetryConfig(String),
447 #[error("Token management error: {0}")]
448 Token(#[from] TokenError),
449}
450
451/// Enhanced error enum for distribution operations and `ElementsRpc`
452#[derive(Error, Debug)]
453pub enum AmpError {
454 #[error("API error: {0}")]
455 Api(String),
456 #[error("API error\n\nEndpoint: {endpoint}\nMethod: {method}\n\nError: {error_message}")]
457 ApiDetailed {
458 /// The API endpoint that was called
459 endpoint: String,
460 /// The HTTP method used
461 method: String,
462 /// The error message
463 error_message: String,
464 },
465
466 #[error("RPC error: {0}")]
467 Rpc(String),
468 #[error("RPC error: {error_message}\n\nMethod: {rpc_method}\nParameters: {params}\n\nRaw Response:\n{raw_response}")]
469 RpcDetailed {
470 /// The RPC method name that was called
471 rpc_method: String,
472 /// The parameters passed to the RPC method
473 params: String,
474 /// The error message
475 error_message: String,
476 /// The complete raw response from the RPC server
477 raw_response: String,
478 },
479
480 #[error("Signer error: {0}")]
481 Signer(#[from] SignerError),
482
483 #[error("Timeout waiting for confirmations: {0}")]
484 Timeout(String),
485
486 #[error("Validation error: {0}")]
487 Validation(String),
488
489 #[error("Network error: {0}")]
490 Network(#[from] reqwest::Error),
491
492 #[error("Serialization error: {0}")]
493 Serialization(#[from] serde_json::Error),
494 #[error("Serialization error: {serde_error}\n\nOperation: {operation}\nData Type: {data_type}\n\nContext: {context}")]
495 SerializationDetailed {
496 /// The serialization operation (serialize/deserialize)
497 operation: String,
498 /// The data type being processed
499 data_type: String,
500 /// Additional context about the operation
501 context: String,
502 /// The original serde error message
503 serde_error: String,
504 },
505
506 #[error(transparent)]
507 Existing(#[from] Error),
508}
509
510impl AmpError {
511 /// Creates a new API error
512 pub fn api<S: Into<String>>(message: S) -> Self {
513 Self::Api(message.into())
514 }
515
516 /// Creates a new RPC error
517 pub fn rpc<S: Into<String>>(message: S) -> Self {
518 Self::Rpc(message.into())
519 }
520
521 /// Creates a new timeout error
522 pub fn timeout<S: Into<String>>(message: S) -> Self {
523 Self::Timeout(message.into())
524 }
525
526 /// Creates a new validation error
527 pub fn validation<S: Into<String>>(message: S) -> Self {
528 Self::Validation(message.into())
529 }
530
531 /// Adds context to an error
532 #[must_use]
533 pub fn with_context<S: Into<String>>(self, context: S) -> Self {
534 let context_str = context.into();
535 match self {
536 Self::Api(msg) => Self::Api(format!("{context_str}: {msg}")),
537 Self::ApiDetailed {
538 endpoint,
539 method,
540 error_message,
541 } => Self::ApiDetailed {
542 endpoint,
543 method,
544 error_message: format!("{context_str}: {error_message}"),
545 },
546 Self::Rpc(msg) => Self::Rpc(format!("{context_str}: {msg}")),
547 Self::RpcDetailed {
548 rpc_method,
549 params,
550 error_message,
551 raw_response,
552 } => Self::RpcDetailed {
553 rpc_method,
554 params,
555 error_message: format!("{context_str}: {error_message}"),
556 raw_response,
557 },
558 Self::Timeout(msg) => Self::Timeout(format!("{context_str}: {msg}")),
559 Self::Validation(msg) => Self::Validation(format!("{context_str}: {msg}")),
560 other => other, // Don't modify other error types
561 }
562 }
563
564 /// Returns true if this error indicates a retryable condition
565 #[must_use]
566 pub const fn is_retryable(&self) -> bool {
567 match self {
568 Self::Network(_) | Self::Rpc(_) | Self::RpcDetailed { .. } => true, // RPC errors might be transient
569 Self::Existing(Error::Token(token_err)) => token_err.is_retryable(),
570 _ => false,
571 }
572 }
573
574 /// Provides user-friendly retry instructions when applicable
575 #[must_use]
576 pub fn retry_instructions(&self) -> Option<String> {
577 match self {
578 Self::Network(_) => Some("Check network connection and retry".to_string()),
579 Self::Rpc(_) | Self::RpcDetailed { .. } => {
580 Some("Check Elements node connection and retry".to_string())
581 }
582 Self::Timeout(msg) if msg.contains("txid") => {
583 Some("Use the transaction ID to manually confirm the distribution".to_string())
584 }
585 Self::Existing(Error::Token(TokenError::RateLimited {
586 retry_after_seconds,
587 })) => Some(format!(
588 "Rate limited. Retry after {retry_after_seconds} seconds"
589 )),
590 _ => None,
591 }
592 }
593}
594
595/// Detailed error types for token management operations
596#[derive(Error, Debug, Clone, PartialEq, Eq)]
597pub enum TokenError {
598 #[error("Token refresh failed: {0}")]
599 RefreshFailed(String),
600 #[error("Token obtain failed after {attempts} attempts: {last_error}")]
601 ObtainFailed { attempts: u32, last_error: String },
602 #[error("Rate limited: retry after {retry_after_seconds} seconds")]
603 RateLimited { retry_after_seconds: u64 },
604 #[error("Request timeout after {timeout_seconds} seconds")]
605 Timeout { timeout_seconds: u64 },
606 #[error("Serialization error: {0}")]
607 Serialization(String),
608 #[error("Token storage error: {0}")]
609 Storage(String),
610 #[error("Token validation error: {0}")]
611 Validation(String),
612}
613
614impl TokenError {
615 /// Creates a new `RefreshFailed` error
616 #[must_use]
617 pub fn refresh_failed<S: Into<String>>(message: S) -> Self {
618 Self::RefreshFailed(message.into())
619 }
620
621 /// Creates a new `ObtainFailed` error
622 #[must_use]
623 pub const fn obtain_failed(attempts: u32, last_error: String) -> Self {
624 Self::ObtainFailed {
625 attempts,
626 last_error,
627 }
628 }
629
630 /// Creates a new `RateLimited` error
631 #[must_use]
632 pub const fn rate_limited(retry_after_seconds: u64) -> Self {
633 Self::RateLimited {
634 retry_after_seconds,
635 }
636 }
637
638 /// Creates a new Timeout error
639 #[must_use]
640 pub const fn timeout(timeout_seconds: u64) -> Self {
641 Self::Timeout { timeout_seconds }
642 }
643
644 /// Creates a new Serialization error
645 #[must_use]
646 pub fn serialization<S: Into<String>>(message: S) -> Self {
647 Self::Serialization(message.into())
648 }
649
650 /// Creates a new Storage error
651 #[must_use]
652 pub fn storage<S: Into<String>>(message: S) -> Self {
653 Self::Storage(message.into())
654 }
655
656 /// Creates a new Validation error
657 #[must_use]
658 pub fn validation<S: Into<String>>(message: S) -> Self {
659 Self::Validation(message.into())
660 }
661
662 /// Returns true if this error indicates a retryable condition
663 #[must_use]
664 pub const fn is_retryable(&self) -> bool {
665 matches!(
666 self,
667 Self::RefreshFailed(_) | Self::RateLimited { .. } | Self::Timeout { .. }
668 )
669 }
670
671 /// Returns true if this error indicates a rate limiting condition
672 #[must_use]
673 pub const fn is_rate_limited(&self) -> bool {
674 matches!(self, Self::RateLimited { .. })
675 }
676
677 /// Returns the retry delay in seconds if this is a rate limited error
678 #[must_use]
679 pub const fn retry_after_seconds(&self) -> Option<u64> {
680 match self {
681 Self::RateLimited {
682 retry_after_seconds,
683 } => Some(*retry_after_seconds),
684 _ => None,
685 }
686 }
687}
688
689// Conversion from serde_json::Error for serialization errors
690impl From<serde_json::Error> for TokenError {
691 fn from(err: serde_json::Error) -> Self {
692 Self::Serialization(err.to_string())
693 }
694}
695
696#[cfg(test)]
697mod amp_error_tests {
698 use super::*;
699
700 #[test]
701 fn test_amp_error_creation_helpers() {
702 let api_error = AmpError::api("Failed to create distribution");
703 assert!(matches!(api_error, AmpError::Api(_)));
704
705 let rpc_error = AmpError::rpc("Elements node connection failed");
706 assert!(matches!(rpc_error, AmpError::Rpc(_)));
707
708 let validation_error = AmpError::validation("Invalid asset UUID format");
709 assert!(matches!(validation_error, AmpError::Validation(_)));
710
711 let timeout_error = AmpError::timeout("Confirmation timeout");
712 assert!(matches!(timeout_error, AmpError::Timeout(_)));
713 }
714
715 #[test]
716 fn test_amp_error_with_context() {
717 let api_error = AmpError::api("Failed to create distribution");
718 let contextual_error = api_error.with_context("During distribution creation");
719
720 match contextual_error {
721 AmpError::Api(msg) => {
722 assert!(msg.contains("During distribution creation"));
723 assert!(msg.contains("Failed to create distribution"));
724 }
725 _ => panic!("Expected Api error variant"),
726 }
727
728 // Test that context doesn't modify errors that already have good context
729 let signer_error = AmpError::Signer(SignerError::Lwk("Test error".to_string()));
730 let contextual_signer = signer_error.with_context("Additional context");
731 assert!(matches!(contextual_signer, AmpError::Signer(_)));
732 }
733
734 #[test]
735 fn test_amp_error_retryability() {
736 let api_error = AmpError::api("Failed to create distribution");
737 assert!(!api_error.is_retryable());
738
739 let rpc_error = AmpError::rpc("Elements node connection failed");
740 assert!(rpc_error.is_retryable());
741
742 let validation_error = AmpError::validation("Invalid asset UUID format");
743 assert!(!validation_error.is_retryable());
744
745 let timeout_error = AmpError::timeout("Confirmation timeout");
746 assert!(!timeout_error.is_retryable());
747
748 let signer_error = AmpError::Signer(SignerError::Lwk("Test error".to_string()));
749 assert!(!signer_error.is_retryable());
750 }
751
752 #[test]
753 fn test_amp_error_retry_instructions() {
754 let rpc_error = AmpError::rpc("Elements node connection failed");
755 let instructions = rpc_error.retry_instructions();
756 assert!(instructions.is_some());
757 assert!(instructions.unwrap().contains("Elements node"));
758
759 let validation_error = AmpError::validation("Invalid asset UUID format");
760 assert!(validation_error.retry_instructions().is_none());
761
762 let timeout_with_txid = AmpError::timeout("Confirmation timeout for txid abc123");
763 let timeout_instructions = timeout_with_txid.retry_instructions();
764 assert!(timeout_instructions.is_some());
765 assert!(timeout_instructions.unwrap().contains("transaction ID"));
766 }
767
768 #[test]
769 fn test_amp_error_display() {
770 let api_error = AmpError::api("Test API error");
771 assert_eq!(format!("{}", api_error), "API error: Test API error");
772
773 let rpc_error = AmpError::rpc("Test RPC error");
774 assert_eq!(format!("{}", rpc_error), "RPC error: Test RPC error");
775
776 let validation_error = AmpError::validation("Test validation error");
777 assert_eq!(
778 format!("{}", validation_error),
779 "Validation error: Test validation error"
780 );
781
782 let timeout_error = AmpError::timeout("Test timeout error");
783 assert_eq!(
784 format!("{}", timeout_error),
785 "Timeout waiting for confirmations: Test timeout error"
786 );
787 }
788
789 #[test]
790 fn test_amp_error_from_conversions() {
791 // Test conversion from SignerError
792 let signer_error = SignerError::Lwk("Test LWK error".to_string());
793 let amp_error = AmpError::from(signer_error);
794 assert!(matches!(amp_error, AmpError::Signer(_)));
795
796 // Test conversion from existing Error
797 let existing_error = Error::MissingEnvVar("TEST_VAR".to_string());
798 let amp_error = AmpError::from(existing_error);
799 assert!(matches!(amp_error, AmpError::Existing(_)));
800
801 // Test conversion from serde_json::Error
802 let json_error = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
803 let amp_error = AmpError::from(json_error);
804 assert!(matches!(amp_error, AmpError::Serialization(_)));
805 }
806}
807
808/// Elements RPC client for blockchain operations
809#[derive(Debug, Clone)]
810pub struct ElementsRpc {
811 client: reqwest::Client,
812 base_url: String,
813 username: String,
814 password: String,
815}
816
817/// Network information from Elements node
818#[derive(Debug, serde::Deserialize)]
819pub struct NetworkInfo {
820 pub version: i64,
821 pub subversion: String,
822 pub protocolversion: i64,
823 pub localservices: String,
824 pub localrelay: bool,
825 pub timeoffset: i64,
826 pub networkactive: bool,
827 pub connections: i64,
828 pub networks: Vec<serde_json::Value>,
829 pub relayfee: f64,
830 pub incrementalfee: f64,
831 pub localaddresses: Vec<serde_json::Value>,
832 pub warnings: String,
833}
834
835/// Blockchain information from Elements node
836#[derive(Debug, serde::Deserialize)]
837pub struct BlockchainInfo {
838 pub chain: String,
839 pub blocks: i64,
840 pub headers: i64,
841 pub bestblockhash: String,
842 #[serde(default)]
843 pub difficulty: Option<f64>,
844 #[serde(default)]
845 pub mediantime: Option<i64>,
846 #[serde(default)]
847 pub verificationprogress: Option<f64>,
848 #[serde(default)]
849 pub initialblockdownload: Option<bool>,
850 #[serde(default)]
851 pub chainwork: Option<String>,
852 #[serde(default)]
853 pub size_on_disk: Option<i64>,
854 #[serde(default)]
855 pub pruned: Option<bool>,
856 #[serde(default)]
857 pub softforks: Option<serde_json::Value>,
858 #[serde(default)]
859 pub warnings: Option<String>,
860}
861
862/// RPC request structure for Elements node
863#[derive(Debug, serde::Serialize)]
864struct RpcRequest {
865 jsonrpc: String,
866 id: String,
867 method: String,
868 params: serde_json::Value,
869}
870
871/// RPC response structure from Elements node
872#[derive(Debug, serde::Deserialize)]
873struct RpcResponse<T> {
874 #[allow(dead_code)]
875 jsonrpc: Option<String>, // Optional for JSON-RPC 1.0 compatib
876 #[allow(dead_code)]
877 id: String,
878 result: Option<T>,
879 error: Option<RpcError>,
880}
881
882/// RPC error structure from Elements node
883#[derive(Debug, serde::Deserialize)]
884struct RpcError {
885 code: i32,
886 message: String,
887}
888
889impl ElementsRpc {
890 /// Creates a new `ElementsRpc` client with connection parameters
891 ///
892 /// # Arguments
893 /// * `url` - The RPC endpoint URL (e.g., <http://localhost:18884>)
894 /// * `username` - RPC authentication username
895 /// * `password` - RPC authentication password
896 ///
897 /// # Examples
898 /// ```
899 /// use amp_rs::ElementsRpc;
900 ///
901 /// let rpc = ElementsRpc::new(
902 /// "http://localhost:18884".to_string(),
903 /// "user".to_string(),
904 /// "pass".to_string()
905 /// );
906 /// ```
907 /// # Panics
908 ///
909 /// Panics if the HTTP client cannot be created.
910 #[must_use]
911 pub fn new(url: String, username: String, password: String) -> Self {
912 let client = reqwest::Client::builder()
913 .timeout(std::time::Duration::from_secs(30))
914 .build()
915 .expect("Failed to create HTTP client");
916
917 Self {
918 client,
919 base_url: url,
920 username,
921 password,
922 }
923 }
924
925 /// Creates a new `ElementsRpc` client from environment variables
926 ///
927 /// Expected environment variables:
928 /// - `ELEMENTS_RPC_URL`: RPC endpoint URL
929 /// - `ELEMENTS_RPC_USER`: RPC username
930 /// - `ELEMENTS_RPC_PASSWORD`: RPC password
931 ///
932 /// # Errors
933 /// Returns an error if any required environment variable is missing
934 ///
935 /// # Examples
936 /// ```no_run
937 /// use amp_rs::ElementsRpc;
938 ///
939 /// let rpc = ElementsRpc::from_env().unwrap();
940 /// ```
941 pub fn from_env() -> Result<Self, AmpError> {
942 let url = env::var("ELEMENTS_RPC_URL")
943 .map_err(|_| AmpError::validation("Missing ELEMENTS_RPC_URL environment variable"))?;
944 let username = env::var("ELEMENTS_RPC_USER")
945 .map_err(|_| AmpError::validation("Missing ELEMENTS_RPC_USER environment variable"))?;
946 let password = env::var("ELEMENTS_RPC_PASSWORD").map_err(|_| {
947 AmpError::validation("Missing ELEMENTS_RPC_PASSWORD environment variable")
948 })?;
949
950 Ok(Self::new(url, username, password))
951 }
952
953 /// Makes an RPC call to the Elements node
954 ///
955 /// # Arguments
956 /// * `method` - The RPC method name
957 /// * `params` - The parameters for the RPC call
958 ///
959 /// # Errors
960 /// Returns an error if the RPC call fails or returns an error
961 async fn rpc_call<T: serde::de::DeserializeOwned>(
962 &self,
963 method: &str,
964 params: serde_json::Value,
965 ) -> Result<T, AmpError> {
966 tracing::debug!("Making RPC call: {} with params: {:?}", method, params);
967
968 let request = RpcRequest {
969 jsonrpc: "1.0".to_string(),
970 id: "amp-client".to_string(),
971 method: method.to_string(),
972 params,
973 };
974
975 let response = self
976 .client
977 .post(&self.base_url)
978 .basic_auth(&self.username, Some(&self.password))
979 .json(&request)
980 .send()
981 .await
982 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
983
984 if !response.status().is_success() {
985 let status = response.status();
986 let error_body = response
987 .text()
988 .await
989 .unwrap_or_else(|_| "Unable to read error body".to_string());
990 return Err(AmpError::rpc(format!(
991 "RPC request failed with status: {status} - Body: {error_body}"
992 )));
993 }
994
995 let rpc_response: RpcResponse<T> = response
996 .json()
997 .await
998 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
999
1000 if let Some(error) = rpc_response.error {
1001 return Err(AmpError::rpc(format!(
1002 "RPC error {}: {}",
1003 error.code, error.message
1004 )));
1005 }
1006
1007 rpc_response
1008 .result
1009 .ok_or_else(|| AmpError::rpc("RPC response missing result field".to_string()))
1010 }
1011
1012 /// Retrieves network information from the Elements node
1013 ///
1014 /// # Errors
1015 /// Returns an error if the RPC call fails
1016 ///
1017 /// # Examples
1018 /// ```no_run
1019 /// # use amp_rs::ElementsRpc;
1020 /// # #[tokio::main]
1021 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1022 /// let rpc = ElementsRpc::from_env()?;
1023 /// let network_info = rpc.get_network_info().await?;
1024 /// println!("Node version: {}", network_info.version);
1025 /// # Ok(())
1026 /// # }
1027 /// ```
1028 pub async fn get_network_info(&self) -> Result<NetworkInfo, AmpError> {
1029 self.rpc_call("getnetworkinfo", serde_json::Value::Array(vec![]))
1030 .await
1031 }
1032
1033 /// Retrieves blockchain information from the Elements node
1034 ///
1035 /// # Errors
1036 /// Returns an error if the RPC call fails
1037 ///
1038 /// # Examples
1039 /// ```no_run
1040 /// # use amp_rs::ElementsRpc;
1041 /// # #[tokio::main]
1042 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1043 /// let rpc = ElementsRpc::from_env()?;
1044 /// let blockchain_info = rpc.get_blockchain_info().await?;
1045 /// println!("Current block height: {}", blockchain_info.blocks);
1046 /// # Ok(())
1047 /// # }
1048 /// ```
1049 pub async fn get_blockchain_info(&self) -> Result<BlockchainInfo, AmpError> {
1050 self.rpc_call("getblockchaininfo", serde_json::Value::Array(vec![]))
1051 .await
1052 }
1053
1054 /// Unlocks the wallet with a passphrase for the specified timeout
1055 ///
1056 /// # Arguments
1057 /// * `passphrase` - The wallet passphrase
1058 /// * `timeout` - Timeout in seconds for the unlock
1059 ///
1060 /// # Errors
1061 /// Returns an error if the RPC call fails
1062 ///
1063 /// # Examples
1064 /// ```no_run
1065 /// # use amp_rs::ElementsRpc;
1066 /// # #[tokio::main]
1067 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1068 /// let rpc = ElementsRpc::from_env()?;
1069 /// rpc.wallet_passphrase("my_passphrase", 300).await?;
1070 /// # Ok(())
1071 /// # }
1072 /// ```
1073 pub async fn wallet_passphrase(&self, passphrase: &str, timeout: u64) -> Result<(), AmpError> {
1074 let params = serde_json::json!([passphrase, timeout]);
1075
1076 // wallet_passphrase returns null on success, so we need to handle this specially
1077 let request = RpcRequest {
1078 jsonrpc: "1.0".to_string(),
1079 id: "amp-client".to_string(),
1080 method: "walletpassphrase".to_string(),
1081 params,
1082 };
1083
1084 let response = self
1085 .client
1086 .post(&self.base_url)
1087 .basic_auth(&self.username, Some(&self.password))
1088 .json(&request)
1089 .send()
1090 .await
1091 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1092
1093 if !response.status().is_success() {
1094 return Err(AmpError::rpc(format!(
1095 "RPC request failed with status: {}",
1096 response.status()
1097 )));
1098 }
1099
1100 let rpc_response: RpcResponse<serde_json::Value> = response
1101 .json()
1102 .await
1103 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1104
1105 if let Some(error) = rpc_response.error {
1106 return Err(AmpError::rpc(format!(
1107 "RPC error {}: {}",
1108 error.code, error.message
1109 )));
1110 }
1111
1112 // For wallet_passphrase, null result is success
1113 Ok(())
1114 }
1115
1116 /// Validates the connection to the Elements node
1117 ///
1118 /// This method performs basic connectivity and authentication checks by
1119 /// retrieving network information from the node.
1120 ///
1121 /// # Errors
1122 /// Returns an error if the connection validation fails
1123 ///
1124 /// # Examples
1125 /// ```no_run
1126 /// # use amp_rs::ElementsRpc;
1127 /// # #[tokio::main]
1128 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1129 /// let rpc = ElementsRpc::from_env()?;
1130 /// rpc.validate_connection().await?;
1131 /// println!("Connection to Elements node is valid");
1132 /// # Ok(())
1133 /// # }
1134 /// ```
1135 pub async fn validate_connection(&self) -> Result<(), AmpError> {
1136 tracing::info!(
1137 "Validating connection to Elements node at {}",
1138 self.base_url
1139 );
1140
1141 let network_info = self
1142 .get_network_info()
1143 .await
1144 .map_err(|e| e.with_context("Failed to validate Elements node connection"))?;
1145
1146 tracing::info!(
1147 "Successfully connected to Elements node - Version: {}, Connections: {}",
1148 network_info.version,
1149 network_info.connections
1150 );
1151
1152 Ok(())
1153 }
1154
1155 /// Retrieves comprehensive node status including network and blockchain information
1156 ///
1157 /// This method combines network and blockchain information to provide a complete
1158 /// status overview of the Elements node.
1159 ///
1160 /// # Errors
1161 /// Returns an error if any RPC call fails
1162 ///
1163 /// # Examples
1164 /// ```no_run
1165 /// # use amp_rs::ElementsRpc;
1166 /// # #[tokio::main]
1167 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1168 /// let rpc = ElementsRpc::from_env()?;
1169 /// let (network_info, blockchain_info) = rpc.get_node_status().await?;
1170 /// println!("Node version: {}, Block height: {}", network_info.version, blockchain_info.blocks);
1171 /// # Ok(())
1172 /// # }
1173 /// ```
1174 pub async fn get_node_status(&self) -> Result<(NetworkInfo, BlockchainInfo), AmpError> {
1175 let network_info = self.get_network_info().await?;
1176 let blockchain_info = self.get_blockchain_info().await?;
1177
1178 Ok((network_info, blockchain_info))
1179 }
1180
1181 /// Lists unspent transaction outputs (UTXOs) for a specific asset
1182 ///
1183 /// # Arguments
1184 /// * `asset_id` - Optional asset ID to filter UTXOs. If None, returns all UTXOs
1185 ///
1186 /// # Errors
1187 /// Returns an error if the RPC call fails
1188 ///
1189 /// # Panics
1190 /// May panic if `asset_id` is `Some` but the warning log message attempts to unwrap it.
1191 /// This is a known logging issue and does not affect normal operation.
1192 ///
1193 /// # Examples
1194 /// ```no_run
1195 /// # use amp_rs::ElementsRpc;
1196 /// # #[tokio::main]
1197 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1198 /// let rpc = ElementsRpc::from_env()?;
1199 /// let utxos = rpc.list_unspent(Some("asset_id_hex")).await?;
1200 /// println!("Found {} UTXOs", utxos.len());
1201 /// # Ok(())
1202 /// # }
1203 /// ```
1204 pub async fn list_unspent(&self, asset_id: Option<&str>) -> Result<Vec<Unspent>, AmpError> {
1205 tracing::debug!("Listing unspent outputs for asset: {:?}", asset_id);
1206
1207 let params = asset_id.map_or_else(
1208 || serde_json::json!([1, 9_999_999, [], true]),
1209 |asset| serde_json::json!([1, 9_999_999, [], true, {"asset": asset}]),
1210 );
1211
1212 let utxos: Vec<Unspent> = self
1213 .rpc_call("listunspent", params)
1214 .await
1215 .map_err(|e| {
1216 if let Some(asset) = asset_id {
1217 e.with_context(format!(
1218 "Failed to list unspent outputs for asset {asset}. \
1219 This may indicate that the treasury address is not imported in the Elements node. \
1220 Ensure the treasury address is properly imported as a watch-only address."
1221 ))
1222 } else {
1223 e.with_context("Failed to list unspent outputs")
1224 }
1225 })?;
1226
1227 tracing::debug!("Found {} unspent outputs", utxos.len());
1228
1229 // If we're looking for a specific asset and found no UTXOs, provide helpful context
1230 if utxos.is_empty() && asset_id.is_some() {
1231 tracing::warn!(
1232 "No UTXOs found for asset {}. This may indicate:\n\
1233 1. The treasury address is not imported in the Elements node\n\
1234 2. The asset issuance transaction hasn't been confirmed yet\n\
1235 3. The UTXOs have already been spent",
1236 asset_id.unwrap()
1237 );
1238 }
1239
1240 Ok(utxos)
1241 }
1242
1243 /// List unspent outputs for a specific wallet
1244 ///
1245 /// This method lists unspent transaction outputs (UTXOs) for a specific wallet,
1246 /// optionally filtered by asset ID.
1247 ///
1248 /// # Arguments
1249 ///
1250 /// * `wallet_name` - Name of the Elements wallet to query
1251 /// * `asset_id` - Optional asset ID to filter UTXOs by
1252 ///
1253 /// # Returns
1254 ///
1255 /// Returns a vector of unspent outputs
1256 ///
1257 /// # Errors
1258 /// Returns an error if the RPC call fails or the wallet cannot be loaded
1259 ///
1260 /// # Panics
1261 /// May panic when processing UTXO blinding data if scriptpubkey is unexpectedly missing.
1262 /// This should not occur under normal operation with valid Elements node responses.
1263 ///
1264 /// # Example
1265 ///
1266 /// ```no_run
1267 /// # use amp_rs::ElementsRpc;
1268 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
1269 /// let rpc = ElementsRpc::from_env()?;
1270 /// // Note: This would need to be called in an async context
1271 /// // let utxos = rpc.list_unspent_for_wallet("test_wallet", None).await?;
1272 /// // println!("Found {} UTXOs", utxos.len());
1273 /// # Ok(())
1274 /// # }
1275 /// ```
1276 #[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
1277 pub async fn list_unspent_for_wallet(
1278 &self,
1279 wallet_name: &str,
1280 asset_id: Option<&str>,
1281 ) -> Result<Vec<Unspent>, AmpError> {
1282 tracing::debug!(
1283 "Listing unspent outputs for wallet {} and asset: {:?}",
1284 wallet_name,
1285 asset_id
1286 );
1287
1288 // First load the wallet to ensure it's available
1289 self.load_wallet(wallet_name).await?;
1290
1291 let params = asset_id.map_or_else(
1292 || serde_json::json!([1, 9_999_999, [], true]),
1293 |asset| serde_json::json!([1, 9_999_999, [], true, {"asset": asset}]),
1294 );
1295
1296 let request = RpcRequest {
1297 jsonrpc: "1.0".to_string(),
1298 id: "amp-client".to_string(),
1299 method: "listunspent".to_string(),
1300 params,
1301 };
1302
1303 // Use the wallet-specific RPC endpoint
1304 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1305
1306 let response = self
1307 .client
1308 .post(&wallet_url)
1309 .basic_auth(&self.username, Some(&self.password))
1310 .json(&request)
1311 .send()
1312 .await
1313 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1314
1315 if !response.status().is_success() {
1316 let status = response.status();
1317 let error_body = response
1318 .text()
1319 .await
1320 .unwrap_or_else(|_| "Unable to read error body".to_string());
1321 return Err(AmpError::rpc(format!(
1322 "RPC request failed with status: {status} - Body: {error_body}"
1323 )));
1324 }
1325
1326 let rpc_response: RpcResponse<Vec<Unspent>> = response
1327 .json()
1328 .await
1329 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1330
1331 if let Some(error) = rpc_response.error {
1332 return Err(AmpError::rpc(format!(
1333 "RPC error listing unspent outputs: {} (code: {})",
1334 error.message, error.code
1335 )));
1336 }
1337
1338 let mut utxos = rpc_response.result.unwrap_or_default();
1339
1340 // Enrich UTXOs with scriptpubkey information if missing
1341 for utxo in &mut utxos {
1342 if utxo.scriptpubkey.is_none() {
1343 tracing::debug!(
1344 "UTXO {}:{} missing scriptpubkey, attempting to derive from address",
1345 utxo.txid,
1346 utxo.vout
1347 );
1348
1349 // Try to derive scriptpubkey from the address
1350 if let Ok(address) = elements::Address::from_str(&utxo.address) {
1351 let script_pubkey = address.script_pubkey();
1352 utxo.scriptpubkey = Some(hex::encode(script_pubkey.as_bytes()));
1353 tracing::info!(
1354 "Derived scriptpubkey for UTXO {}:{} from address {}: {}",
1355 utxo.txid,
1356 utxo.vout,
1357 utxo.address,
1358 utxo.scriptpubkey.as_ref().unwrap()
1359 );
1360 } else {
1361 tracing::error!(
1362 "Failed to parse address {} for UTXO {}:{}",
1363 utxo.address,
1364 utxo.txid,
1365 utxo.vout
1366 );
1367
1368 // Fallback: try to get transaction details
1369 match self.get_transaction(&utxo.txid).await {
1370 Ok(tx_detail) => {
1371 tracing::debug!(
1372 "Retrieved transaction details for {} as fallback",
1373 utxo.txid
1374 );
1375 // Parse the transaction hex to extract the scriptpubkey for this output
1376 match hex::decode(&tx_detail.hex) {
1377 Ok(tx_bytes) => {
1378 match elements::Transaction::consensus_decode(&tx_bytes[..]) {
1379 Ok(tx) => {
1380 if let Some(output) = tx.output.get(utxo.vout as usize)
1381 {
1382 utxo.scriptpubkey = Some(hex::encode(
1383 output.script_pubkey.as_bytes(),
1384 ));
1385 tracing::info!("Enriched UTXO {}:{} with scriptpubkey from transaction: {}",
1386 utxo.txid, utxo.vout, utxo.scriptpubkey.as_ref().unwrap());
1387 } else {
1388 tracing::error!(
1389 "Output {} not found in transaction {}",
1390 utxo.vout,
1391 utxo.txid
1392 );
1393 }
1394 }
1395 Err(e) => {
1396 tracing::error!(
1397 "Failed to decode transaction {}: {}",
1398 utxo.txid,
1399 e
1400 );
1401 }
1402 }
1403 }
1404 Err(e) => {
1405 tracing::error!(
1406 "Failed to decode hex for transaction {}: {}",
1407 utxo.txid,
1408 e
1409 );
1410 }
1411 }
1412 }
1413 Err(e) => {
1414 tracing::error!(
1415 "Failed to get transaction details for {}: {}",
1416 utxo.txid,
1417 e
1418 );
1419 }
1420 }
1421 }
1422 } else {
1423 tracing::debug!("UTXO {}:{} already has scriptpubkey", utxo.txid, utxo.vout);
1424 }
1425 }
1426
1427 tracing::debug!(
1428 "Found {} unspent outputs for wallet {}",
1429 utxos.len(),
1430 wallet_name
1431 );
1432
1433 // If we're looking for a specific asset and found no UTXOs, provide helpful context
1434 if utxos.is_empty() && asset_id.is_some() {
1435 tracing::warn!(
1436 "No UTXOs found for asset {} in wallet {}. This may indicate:\n\
1437 1. The asset issuance transaction hasn't been confirmed yet\n\
1438 2. The UTXOs have already been spent\n\
1439 3. The wallet doesn't contain the expected addresses",
1440 asset_id.unwrap(),
1441 wallet_name
1442 );
1443 }
1444
1445 Ok(utxos)
1446 }
1447
1448 /// Creates a raw transaction with the specified inputs and outputs
1449 ///
1450 /// # Arguments
1451 /// * `inputs` - Vector of transaction inputs (UTXOs to spend)
1452 /// * `outputs` - Map of addresses to amounts for regular outputs
1453 /// * `assets` - Map of addresses to asset IDs for Liquid-specific outputs
1454 ///
1455 /// # Errors
1456 /// Returns an error if the RPC call fails or transaction creation fails
1457 ///
1458 /// # Examples
1459 /// ```no_run
1460 /// # use amp_rs::{ElementsRpc, model::{TxInput}};
1461 /// # use std::collections::HashMap;
1462 /// # #[tokio::main]
1463 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1464 /// let rpc = ElementsRpc::from_env()?;
1465 /// let inputs = vec![TxInput {
1466 /// txid: "abc123".to_string(),
1467 /// vout: 0,
1468 /// sequence: None,
1469 /// }];
1470 /// let mut outputs = HashMap::new();
1471 /// outputs.insert("address1".to_string(), 100.0);
1472 /// let mut assets = HashMap::new();
1473 /// assets.insert("address1".to_string(), "asset_id".to_string());
1474 /// let raw_tx = rpc.create_raw_transaction(inputs, outputs, assets).await?;
1475 /// # Ok(())
1476 /// # }
1477 /// ```
1478 #[allow(clippy::cognitive_complexity)]
1479 pub async fn create_raw_transaction(
1480 &self,
1481 inputs: Vec<TxInput>,
1482 outputs: std::collections::HashMap<String, f64>,
1483 assets: std::collections::HashMap<String, String>,
1484 ) -> Result<String, AmpError> {
1485 tracing::debug!(
1486 "Creating raw transaction with {} inputs and {} outputs",
1487 inputs.len(),
1488 outputs.len()
1489 );
1490
1491 // Elements RPC createrawtransaction expects:
1492 // createrawtransaction inputs outputs locktime replaceable assets
1493 let params = serde_json::json!([
1494 inputs, // inputs as TxInput array
1495 outputs, // outputs as address->amount map
1496 0, // locktime (0 = no locktime)
1497 false, // replaceable (false = not replaceable)
1498 assets // assets as address->asset_id map
1499 ]);
1500
1501 // Debug: Log the exact parameters being sent to createrawtransaction
1502 tracing::error!("createrawtransaction parameters:");
1503 tracing::error!(
1504 " inputs: {}",
1505 serde_json::to_string_pretty(&inputs).unwrap_or_default()
1506 );
1507 tracing::error!(
1508 " outputs: {}",
1509 serde_json::to_string_pretty(&outputs).unwrap_or_default()
1510 );
1511 tracing::error!(
1512 " assets: {}",
1513 serde_json::to_string_pretty(&assets).unwrap_or_default()
1514 );
1515
1516 let raw_tx: String = self
1517 .rpc_call("createrawtransaction", params)
1518 .await
1519 .map_err(|e| {
1520 tracing::error!("createrawtransaction RPC call failed: {}", e);
1521 e.with_context("Failed to create raw transaction")
1522 })?;
1523
1524 tracing::debug!("Created raw transaction: {}", raw_tx);
1525 Ok(raw_tx)
1526 }
1527
1528 /// Imports an address into a specific wallet as watch-only
1529 ///
1530 /// # Arguments
1531 /// * `wallet_name` - Name of the wallet to import into
1532 /// * `address` - The address to import
1533 /// * `label` - Optional label for the address
1534 /// * `rescan` - Whether to rescan the blockchain for transactions
1535 ///
1536 /// # Errors
1537 /// Returns an error if the RPC call fails
1538 async fn import_address_to_wallet(
1539 &self,
1540 wallet_name: &str,
1541 address: &str,
1542 label: Option<&str>,
1543 rescan: bool,
1544 ) -> Result<(), AmpError> {
1545 tracing::debug!("Importing address {} into wallet {}", address, wallet_name);
1546
1547 // First load the wallet to ensure it's available
1548 self.load_wallet(wallet_name).await?;
1549
1550 let params = serde_json::json!([address, label.unwrap_or(""), rescan]);
1551
1552 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1553
1554 let request = RpcRequest {
1555 jsonrpc: "1.0".to_string(),
1556 id: "amp-client".to_string(),
1557 method: "importaddress".to_string(),
1558 params,
1559 };
1560
1561 let response = self
1562 .client
1563 .post(&wallet_url)
1564 .basic_auth(&self.username, Some(&self.password))
1565 .json(&request)
1566 .send()
1567 .await
1568 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1569
1570 if !response.status().is_success() {
1571 let status = response.status();
1572 let error_body = response
1573 .text()
1574 .await
1575 .unwrap_or_else(|_| "Unable to read error body".to_string());
1576 return Err(AmpError::rpc(format!(
1577 "RPC request failed with status: {status} - Body: {error_body}"
1578 )));
1579 }
1580
1581 let rpc_response: RpcResponse<serde_json::Value> = response
1582 .json()
1583 .await
1584 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1585
1586 if let Some(error) = rpc_response.error {
1587 // Ignore "already imported" errors
1588 if error.code != -4 {
1589 return Err(AmpError::rpc(format!(
1590 "RPC error importing address: {} (code: {})",
1591 error.message, error.code
1592 )));
1593 }
1594 }
1595
1596 tracing::debug!(
1597 "Successfully imported address {} into wallet {}",
1598 address,
1599 wallet_name
1600 );
1601 Ok(())
1602 }
1603
1604 /// Creates a raw transaction using a specific wallet context
1605 ///
1606 /// This method uses the wallet-specific RPC endpoint which is necessary
1607 /// for confidential transactions that require wallet context for blinding keys.
1608 ///
1609 /// # Arguments
1610 /// * `wallet_name` - Name of the wallet to use for transaction creation
1611 /// * `inputs` - Transaction inputs
1612 /// * `outputs` - Map of addresses to amounts
1613 /// * `assets` - Map of addresses to asset IDs
1614 ///
1615 /// # Returns
1616 /// Returns the raw transaction hex
1617 ///
1618 /// # Errors
1619 /// Returns an error if the RPC call fails
1620 #[allow(dead_code)]
1621 #[allow(clippy::cognitive_complexity)]
1622 async fn create_raw_transaction_with_wallet(
1623 &self,
1624 wallet_name: &str,
1625 inputs: Vec<TxInput>,
1626 outputs: std::collections::HashMap<String, f64>,
1627 assets: std::collections::HashMap<String, String>,
1628 ) -> Result<String, AmpError> {
1629 tracing::debug!(
1630 "Creating raw transaction with wallet {} - {} inputs and {} outputs",
1631 wallet_name,
1632 inputs.len(),
1633 outputs.len()
1634 );
1635
1636 // First load the wallet to ensure it's available
1637 self.load_wallet(wallet_name).await?;
1638
1639 // Elements RPC createrawtransaction expects outputs as an array of objects
1640 // Each output object should contain both address, amount, and asset
1641 let mut outputs_array = Vec::new();
1642
1643 for (address, amount) in &outputs {
1644 let asset_id = assets.get(address).ok_or_else(|| {
1645 AmpError::validation(format!("No asset ID found for address {address}"))
1646 })?;
1647
1648 // Convert amount to string with proper precision for Elements
1649 let amount_str = format!("{amount:.8}");
1650
1651 outputs_array.push(serde_json::json!({
1652 address.clone(): amount_str,
1653 "asset": asset_id
1654 }));
1655 }
1656
1657 let params = serde_json::json!([
1658 inputs, // inputs as TxInput array
1659 outputs_array, // outputs as array of {address: amount, asset: id} objects
1660 0, // locktime (0 = no locktime)
1661 false, // replaceable (false = not replaceable)
1662 ]);
1663
1664 // Debug: Log the exact parameters being sent to createrawtransaction
1665 tracing::error!("createrawtransaction parameters (wallet-specific, corrected format):");
1666 tracing::error!(" wallet: {}", wallet_name);
1667 tracing::error!(
1668 " inputs: {}",
1669 serde_json::to_string_pretty(&inputs).unwrap_or_default()
1670 );
1671 tracing::error!(
1672 " outputs_array: {}",
1673 serde_json::to_string_pretty(&outputs_array).unwrap_or_default()
1674 );
1675
1676 // Use the wallet-specific RPC endpoint
1677 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1678
1679 let request = RpcRequest {
1680 jsonrpc: "1.0".to_string(),
1681 id: "amp-client".to_string(),
1682 method: "createrawtransaction".to_string(),
1683 params,
1684 };
1685
1686 let response = self
1687 .client
1688 .post(&wallet_url)
1689 .basic_auth(&self.username, Some(&self.password))
1690 .json(&request)
1691 .send()
1692 .await
1693 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1694
1695 if !response.status().is_success() {
1696 let status = response.status();
1697 let error_body = response
1698 .text()
1699 .await
1700 .unwrap_or_else(|_| "Unable to read error body".to_string());
1701 return Err(AmpError::rpc(format!(
1702 "RPC request failed with status: {status} - Body: {error_body}"
1703 )));
1704 }
1705
1706 let rpc_response: RpcResponse<String> = response
1707 .json()
1708 .await
1709 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1710
1711 if let Some(error) = rpc_response.error {
1712 return Err(AmpError::rpc(format!(
1713 "RPC error creating raw transaction: {} (code: {})",
1714 error.message, error.code
1715 )));
1716 }
1717
1718 let raw_tx = rpc_response
1719 .result
1720 .ok_or_else(|| AmpError::rpc("No raw transaction returned".to_string()))?;
1721
1722 tracing::debug!(
1723 "Created raw transaction with wallet {}: {}",
1724 wallet_name,
1725 raw_tx
1726 );
1727 Ok(raw_tx)
1728 }
1729
1730 /// Imports an address into a specific wallet as watch-only
1731 ///
1732 /// # Arguments
1733 /// * `wallet_name` - Name of the wallet
1734 /// * `address` - The address to import
1735 /// * `label` - Optional label for the address
1736 /// * `rescan` - Optional whether to rescan the blockchain (default: false)
1737 ///
1738 /// # Errors
1739 /// Returns an error if the RPC call fails
1740 ///
1741 /// # Examples
1742 /// ```no_run
1743 /// # use amp_rs::ElementsRpc;
1744 /// # #[tokio::main]
1745 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1746 /// let rpc = ElementsRpc::from_env()?;
1747 /// rpc.import_address("my_wallet", "vjU8L4dKa1XyyVcPqKBbTgjT1tRC7qYp5VJGwndZSCFk4ntpWey1pQe6hcSGDMVurr9CsZ21EGsqGjWA", Some("test_address"), Some(false)).await?;
1748 /// # Ok(())
1749 /// # }
1750 /// ```
1751 pub async fn import_address(
1752 &self,
1753 wallet_name: &str,
1754 address: &str,
1755 label: Option<&str>,
1756 rescan: Option<bool>,
1757 ) -> Result<(), AmpError> {
1758 let rescan_value = rescan.unwrap_or(false);
1759 tracing::debug!(
1760 "Importing address: {} into wallet: {} with label: {:?}, rescan: {}",
1761 address,
1762 wallet_name,
1763 label,
1764 rescan_value
1765 );
1766
1767 let params = serde_json::json!([address, label.unwrap_or(""), rescan_value]);
1768
1769 // importaddress returns null on success
1770 let request = RpcRequest {
1771 jsonrpc: "1.0".to_string(),
1772 id: "amp-client".to_string(),
1773 method: "importaddress".to_string(),
1774 params,
1775 };
1776
1777 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1778
1779 let response = self
1780 .client
1781 .post(&wallet_url)
1782 .basic_auth(&self.username, Some(&self.password))
1783 .json(&request)
1784 .send()
1785 .await
1786 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1787
1788 if !response.status().is_success() {
1789 return Err(AmpError::rpc(format!(
1790 "RPC request failed with status: {}",
1791 response.status()
1792 )));
1793 }
1794
1795 let rpc_response: RpcResponse<serde_json::Value> = response
1796 .json()
1797 .await
1798 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1799
1800 if let Some(error) = rpc_response.error {
1801 return Err(AmpError::rpc(format!(
1802 "RPC error {}: {}",
1803 error.code, error.message
1804 )));
1805 }
1806
1807 tracing::debug!(
1808 "Successfully imported address: {} into wallet: {}",
1809 address,
1810 wallet_name
1811 );
1812 Ok(())
1813 }
1814
1815 /// Rescans the blockchain for a wallet
1816 ///
1817 /// # Arguments
1818 /// * `wallet_name` - Name of the wallet to rescan
1819 /// * `start_height` - Optional start height for rescan (default: 0)
1820 ///
1821 /// # Errors
1822 /// Returns an error if the RPC call fails
1823 ///
1824 /// # Examples
1825 /// ```no_run
1826 /// # use amp_rs::ElementsRpc;
1827 /// # #[tokio::main]
1828 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1829 /// let rpc = ElementsRpc::from_env()?;
1830 /// let result = rpc.rescan_blockchain("my_wallet", None).await?;
1831 /// # Ok(())
1832 /// # }
1833 /// ```
1834 pub async fn rescan_blockchain(
1835 &self,
1836 wallet_name: &str,
1837 start_height: Option<u64>,
1838 ) -> Result<serde_json::Value, AmpError> {
1839 tracing::debug!("Rescanning blockchain for wallet: {}", wallet_name);
1840
1841 let params = start_height.map_or_else(
1842 || serde_json::json!([]),
1843 |height| serde_json::json!([height]),
1844 );
1845
1846 let request = RpcRequest {
1847 jsonrpc: "1.0".to_string(),
1848 id: "amp-client".to_string(),
1849 method: "rescanblockchain".to_string(),
1850 params,
1851 };
1852
1853 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
1854
1855 let response = self
1856 .client
1857 .post(&wallet_url)
1858 .basic_auth(&self.username, Some(&self.password))
1859 .json(&request)
1860 .send()
1861 .await
1862 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1863
1864 if !response.status().is_success() {
1865 return Err(AmpError::rpc(format!(
1866 "RPC request failed with status: {}",
1867 response.status()
1868 )));
1869 }
1870
1871 let rpc_response: RpcResponse<serde_json::Value> = response
1872 .json()
1873 .await
1874 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1875
1876 if let Some(error) = rpc_response.error {
1877 return Err(AmpError::rpc(format!(
1878 "RPC error rescanning blockchain: {} (code: {})",
1879 error.message, error.code
1880 )));
1881 }
1882
1883 let result = rpc_response
1884 .result
1885 .ok_or_else(|| AmpError::rpc("No result returned from rescanblockchain".to_string()))?;
1886
1887 tracing::debug!(
1888 "Successfully rescanned blockchain for wallet: {}",
1889 wallet_name
1890 );
1891 Ok(result)
1892 }
1893
1894 /// Creates or loads a wallet
1895 ///
1896 /// # Arguments
1897 /// * `wallet_name` - Name of the wallet to create or load
1898 /// * `disable_private_keys` - Whether to disable private keys (watch-only wallet)
1899 ///
1900 /// # Errors
1901 /// Returns an error if the RPC call fails
1902 ///
1903 /// # Examples
1904 /// ```no_run
1905 /// # use amp_rs::ElementsRpc;
1906 /// # #[tokio::main]
1907 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1908 /// let rpc = ElementsRpc::from_env()?;
1909 /// rpc.create_wallet("test_wallet", true).await?;
1910 /// # Ok(())
1911 /// # }
1912 /// ```
1913 pub async fn create_wallet(
1914 &self,
1915 wallet_name: &str,
1916 disable_private_keys: bool,
1917 ) -> Result<(), AmpError> {
1918 tracing::debug!(
1919 "Creating wallet: {} with disable_private_keys: {}",
1920 wallet_name,
1921 disable_private_keys
1922 );
1923
1924 let params = serde_json::json!([wallet_name, disable_private_keys]);
1925
1926 let request = RpcRequest {
1927 jsonrpc: "1.0".to_string(),
1928 id: "amp-client".to_string(),
1929 method: "createwallet".to_string(),
1930 params,
1931 };
1932
1933 let response = self
1934 .client
1935 .post(&self.base_url)
1936 .basic_auth(&self.username, Some(&self.password))
1937 .json(&request)
1938 .send()
1939 .await
1940 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
1941
1942 if !response.status().is_success() {
1943 return Err(AmpError::rpc(format!(
1944 "RPC request failed with status: {}",
1945 response.status()
1946 )));
1947 }
1948
1949 let rpc_response: RpcResponse<serde_json::Value> = response
1950 .json()
1951 .await
1952 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
1953
1954 if let Some(error) = rpc_response.error {
1955 // Ignore "wallet already exists" error
1956 if error.code != -4 {
1957 return Err(AmpError::rpc(format!(
1958 "RPC error {}: {}",
1959 error.code, error.message
1960 )));
1961 }
1962 tracing::debug!("Wallet {} already exists", wallet_name);
1963 } else {
1964 tracing::debug!("Successfully created wallet: {}", wallet_name);
1965 }
1966
1967 Ok(())
1968 }
1969
1970 /// Loads an existing wallet
1971 ///
1972 /// # Arguments
1973 /// * `wallet_name` - Name of the wallet to load
1974 ///
1975 /// # Errors
1976 /// Returns an error if the RPC call fails
1977 ///
1978 /// # Examples
1979 /// ```no_run
1980 /// # use amp_rs::ElementsRpc;
1981 /// # #[tokio::main]
1982 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
1983 /// let rpc = ElementsRpc::from_env()?;
1984 /// rpc.load_wallet("test_wallet").await?;
1985 /// # Ok(())
1986 /// # }
1987 /// ```
1988 #[allow(clippy::cognitive_complexity)]
1989 pub async fn load_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
1990 tracing::debug!("Loading wallet: {}", wallet_name);
1991
1992 let params = serde_json::json!([wallet_name]);
1993
1994 let request = RpcRequest {
1995 jsonrpc: "1.0".to_string(),
1996 id: "amp-client".to_string(),
1997 method: "loadwallet".to_string(),
1998 params,
1999 };
2000
2001 let response = self
2002 .client
2003 .post(&self.base_url)
2004 .basic_auth(&self.username, Some(&self.password))
2005 .json(&request)
2006 .send()
2007 .await
2008 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
2009
2010 if !response.status().is_success() {
2011 let status = response.status();
2012 let error_body = response
2013 .text()
2014 .await
2015 .unwrap_or_else(|_| "Unable to read error body".to_string());
2016 tracing::debug!(
2017 "Load wallet failed with status: {} - Body: {}",
2018 status,
2019 error_body
2020 );
2021
2022 // For wallet loading, we want to be more permissive with errors
2023 // since the wallet might already be loaded
2024 if status == 500 && error_body.contains("already loaded") {
2025 tracing::debug!(
2026 "Wallet {} appears to already be loaded (500 error)",
2027 wallet_name
2028 );
2029 return Ok(());
2030 }
2031
2032 return Err(AmpError::rpc(format!(
2033 "RPC request failed with status: {status} - Body: {error_body}"
2034 )));
2035 }
2036
2037 let rpc_response: RpcResponse<serde_json::Value> = response
2038 .json()
2039 .await
2040 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
2041
2042 if let Some(error) = rpc_response.error {
2043 // Ignore "wallet already loaded" error
2044 if error.code != -35 {
2045 return Err(AmpError::rpc(format!(
2046 "RPC error {}: {}",
2047 error.code, error.message
2048 )));
2049 }
2050 tracing::debug!("Wallet {} already loaded", wallet_name);
2051 } else {
2052 tracing::debug!("Successfully loaded wallet: {}", wallet_name);
2053 }
2054
2055 Ok(())
2056 }
2057
2058 /// Unloads a wallet
2059 ///
2060 /// # Arguments
2061 /// * `wallet_name` - Name of the wallet to unload
2062 ///
2063 /// # Errors
2064 /// Returns an error if the RPC call fails
2065 ///
2066 /// # Examples
2067 /// ```no_run
2068 /// # use amp_rs::ElementsRpc;
2069 /// # #[tokio::main]
2070 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2071 /// let rpc = ElementsRpc::from_env()?;
2072 /// rpc.unload_wallet("test_wallet").await?;
2073 /// # Ok(())
2074 /// # }
2075 /// ```
2076 pub async fn unload_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
2077 tracing::debug!("Unloading wallet: {}", wallet_name);
2078
2079 let params = serde_json::json!([wallet_name]);
2080
2081 let request = RpcRequest {
2082 jsonrpc: "1.0".to_string(),
2083 id: "amp-client".to_string(),
2084 method: "unloadwallet".to_string(),
2085 params,
2086 };
2087
2088 let response = self
2089 .client
2090 .post(&self.base_url)
2091 .basic_auth(&self.username, Some(&self.password))
2092 .json(&request)
2093 .send()
2094 .await
2095 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
2096
2097 if !response.status().is_success() {
2098 return Err(AmpError::rpc(format!(
2099 "RPC request failed with status: {}",
2100 response.status()
2101 )));
2102 }
2103
2104 let rpc_response: RpcResponse<serde_json::Value> = response
2105 .json()
2106 .await
2107 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
2108
2109 if let Some(error) = rpc_response.error {
2110 return Err(AmpError::rpc(format!(
2111 "RPC error {}: {}",
2112 error.code, error.message
2113 )));
2114 }
2115
2116 tracing::debug!("Successfully unloaded wallet: {}", wallet_name);
2117 Ok(())
2118 }
2119
2120 /// Lists all available wallets
2121 ///
2122 /// # Errors
2123 /// Returns an error if the RPC call fails
2124 ///
2125 /// # Examples
2126 /// ```no_run
2127 /// # use amp_rs::ElementsRpc;
2128 /// # #[tokio::main]
2129 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2130 /// let rpc = ElementsRpc::from_env()?;
2131 /// let wallets = rpc.list_wallets().await?;
2132 /// println!("Available wallets: {:?}", wallets);
2133 /// # Ok(())
2134 /// # }
2135 /// ```
2136 pub async fn list_wallets(&self) -> Result<Vec<String>, AmpError> {
2137 tracing::debug!("Listing available wallets");
2138
2139 let params = serde_json::json!([]);
2140
2141 let wallets: Vec<String> = self
2142 .rpc_call("listwallets", params)
2143 .await
2144 .map_err(|e| e.with_context("Failed to list wallets"))?;
2145
2146 tracing::debug!("Found {} wallets", wallets.len());
2147 Ok(wallets)
2148 }
2149
2150 /// Sets up a watch-only wallet with the given address
2151 ///
2152 /// This is a convenience method that creates a watch-only wallet and imports the address
2153 ///
2154 /// # Arguments
2155 /// * `wallet_name` - Name of the wallet to create
2156 /// * `address` - Address to import as watch-only
2157 /// * `label` - Optional label for the address
2158 ///
2159 /// # Errors
2160 /// Returns an error if wallet creation or address import fails
2161 ///
2162 /// # Examples
2163 /// ```no_run
2164 /// # use amp_rs::ElementsRpc;
2165 /// # #[tokio::main]
2166 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2167 /// let rpc = ElementsRpc::from_env()?;
2168 /// rpc.setup_watch_only_wallet("test_wallet", "vjU8L4dKa1XyyVcPqKBbTgjT1tRC7qYp5VJGwndZSCFk4ntpWey1pQe6hcSGDMVurr9CsZ21EGsqGjWA", Some("treasury")).await?;
2169 /// # Ok(())
2170 /// # }
2171 /// ```
2172 #[allow(clippy::cognitive_complexity)]
2173 pub async fn setup_watch_only_wallet(
2174 &self,
2175 wallet_name: &str,
2176 address: &str,
2177 label: Option<&str>,
2178 ) -> Result<(), AmpError> {
2179 tracing::info!(
2180 "Setting up watch-only wallet '{}' with address: {}",
2181 wallet_name,
2182 address
2183 );
2184
2185 // Try the full wallet setup approach first
2186 match self
2187 .setup_wallet_with_address(wallet_name, address, label)
2188 .await
2189 {
2190 Ok(()) => {
2191 tracing::info!(
2192 "Successfully set up watch-only wallet '{}' with address: {}",
2193 wallet_name,
2194 address
2195 );
2196 return Ok(());
2197 }
2198 Err(e) => {
2199 tracing::warn!(
2200 "Full wallet setup failed: {}, trying direct address import",
2201 e
2202 );
2203 }
2204 }
2205
2206 // Fallback: Try to import the address directly without wallet operations
2207 match self.import_address_direct(address, label) {
2208 Ok(()) => {
2209 tracing::info!("Successfully imported address directly: {}", address);
2210 Ok(())
2211 }
2212 Err(e) => {
2213 tracing::error!(
2214 "Both wallet setup and direct import failed for address: {}",
2215 address
2216 );
2217 Err(AmpError::rpc(format!(
2218 "Failed to set up watch-only wallet or import address: wallet setup error: {e}, direct import error: {e}"
2219 )))
2220 }
2221 }
2222 }
2223
2224 /// Attempts to set up a wallet with address using the standard approach
2225 async fn setup_wallet_with_address(
2226 &self,
2227 wallet_name: &str,
2228 address: &str,
2229 label: Option<&str>,
2230 ) -> Result<(), AmpError> {
2231 // Try to create the wallet (will ignore if it already exists)
2232 self.create_wallet(wallet_name, true).await?;
2233
2234 // Try to load the wallet (will ignore if already loaded)
2235 self.load_wallet(wallet_name).await?;
2236
2237 // Import the address without rescanning (for faster setup)
2238 self.import_address(wallet_name, address, label, Some(false))
2239 .await?;
2240
2241 Ok(())
2242 }
2243
2244 /// Attempts to import an address directly without wallet operations (uses default wallet)
2245 #[allow(clippy::unused_self)]
2246 fn import_address_direct(&self, address: &str, _label: Option<&str>) -> Result<(), AmpError> {
2247 tracing::debug!("Attempting direct address import for: {}", address);
2248
2249 // This is a fallback method - we'll use empty string for wallet name to use default behavior
2250 // Note: This may not work as expected with the new signature, but kept for compatibility
2251 Err(AmpError::rpc(
2252 "Direct address import not supported with wallet-specific import_address".to_string(),
2253 ))
2254 }
2255
2256 /// Broadcasts a signed raw transaction to the network
2257 ///
2258 /// # Arguments
2259 /// * `hex` - The signed transaction in hexadecimal format
2260 ///
2261 /// # Errors
2262 /// Returns an error if the RPC call fails or transaction broadcast fails
2263 ///
2264 /// # Examples
2265 /// ```no_run
2266 /// # use amp_rs::ElementsRpc;
2267 /// # #[tokio::main]
2268 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2269 /// let rpc = ElementsRpc::from_env()?;
2270 /// let signed_tx_hex = "0200000000..."; // Signed transaction hex
2271 /// let txid = rpc.send_raw_transaction(signed_tx_hex).await?;
2272 /// println!("Transaction broadcast with ID: {}", txid);
2273 /// # Ok(())
2274 /// # }
2275 /// ```
2276 pub async fn send_raw_transaction(&self, hex: &str) -> Result<String, AmpError> {
2277 tracing::debug!(
2278 "Broadcasting raw transaction: {}",
2279 &hex[..std::cmp::min(hex.len(), 64)]
2280 );
2281
2282 let params = serde_json::json!([hex]);
2283
2284 let txid: String = self
2285 .rpc_call("sendrawtransaction", params)
2286 .await
2287 .map_err(|e| {
2288 tracing::error!("Raw transaction broadcast failed: {}", e);
2289 tracing::error!("Transaction hex (first 200 chars): {}", &hex[..std::cmp::min(hex.len(), 200)]);
2290
2291 // Provide specific guidance for blinding-related errors
2292 if e.to_string().contains("bad-txns-in-ne-out") || e.to_string().contains("value in != value out") {
2293 AmpError::rpc(format!(
2294 "Transaction broadcast failed due to confidential transaction blinding error. \
2295 This indicates that the blinding factors don't balance properly. \
2296 Possible solutions:\n\
2297 1. Ensure all addresses have proper blinding keys in the wallet\n\
2298 2. Verify that blindrawtransaction was called before signing\n\
2299 3. Check that UTXO blinding factors match between Elements and LWK\n\
2300 4. Try using unconfidential addresses for testing\n\
2301 Original error: {e}"
2302 ))
2303 } else {
2304 e.with_context("Failed to broadcast raw transaction")
2305 }
2306 })?;
2307
2308 tracing::info!("Successfully broadcast transaction with ID: {}", txid);
2309 Ok(txid)
2310 }
2311
2312 /// Retrieves detailed information about a transaction
2313 ///
2314 /// # Arguments
2315 /// * `txid` - The transaction ID to retrieve
2316 ///
2317 /// # Errors
2318 /// Returns an error if the RPC call fails or transaction is not found
2319 ///
2320 /// # Examples
2321 /// ```no_run
2322 /// # use amp_rs::ElementsRpc;
2323 /// # #[tokio::main]
2324 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2325 /// let rpc = ElementsRpc::from_env()?;
2326 /// let tx_detail = rpc.get_transaction("abc123...").await?;
2327 /// println!("Transaction has {} confirmations", tx_detail.confirmations);
2328 /// # Ok(())
2329 /// # }
2330 /// ```
2331 pub async fn get_transaction(&self, txid: &str) -> Result<TransactionDetail, AmpError> {
2332 tracing::debug!("Retrieving transaction details for: {}", txid);
2333
2334 let params = serde_json::json!([txid, true]); // true for verbose output
2335
2336 let tx_detail: TransactionDetail = self
2337 .rpc_call("gettransaction", params)
2338 .await
2339 .map_err(|e| e.with_context(format!("Failed to get transaction details for {txid}")))?;
2340
2341 tracing::debug!(
2342 "Retrieved transaction {} with {} confirmations",
2343 txid,
2344 tx_detail.confirmations
2345 );
2346
2347 Ok(tx_detail)
2348 }
2349
2350 /// Sends multiple outputs to multiple addresses using Elements' sendmany RPC
2351 ///
2352 /// This method uses Elements' built-in sendmany command which properly handles
2353 /// confidential transactions and blinding. This is the recommended approach for
2354 /// asset distribution as it avoids manual transaction construction issues.
2355 ///
2356 /// # Arguments
2357 /// * `wallet_name` - Name of the Elements wallet to use
2358 /// * `address_amounts` - Map of addresses to amounts to send
2359 /// * `asset_amounts` - Map of addresses to asset IDs for each output
2360 /// * `min_conf` - Minimum confirmations for inputs (default: 1)
2361 /// * `comment` - Optional transaction comment
2362 /// * `subtract_fee_from` - Optional addresses to subtract fees from
2363 /// * `replaceable` - Whether transaction is replaceable (default: false)
2364 /// * `conf_target` - Confirmation target for fee estimation (default: 1)
2365 /// * `estimate_mode` - Fee estimation mode (default: "UNSET")
2366 ///
2367 /// # Returns
2368 /// Returns the transaction ID of the sent transaction
2369 ///
2370 /// # Errors
2371 /// Returns an error if the RPC call fails or transaction creation fails
2372 ///
2373 /// # Examples
2374 /// ```no_run
2375 /// # use amp_rs::ElementsRpc;
2376 /// # use std::collections::HashMap;
2377 /// # #[tokio::main]
2378 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2379 /// let rpc = ElementsRpc::from_env()?;
2380 ///
2381 /// let mut address_amounts = HashMap::new();
2382 /// address_amounts.insert("address1".to_string(), 100.0);
2383 /// address_amounts.insert("address2".to_string(), 50.0);
2384 ///
2385 /// let mut asset_amounts = HashMap::new();
2386 /// asset_amounts.insert("address1".to_string(), "asset_id_hex".to_string());
2387 /// asset_amounts.insert("address2".to_string(), "asset_id_hex".to_string());
2388 ///
2389 /// let txid = rpc.sendmany("wallet_name", address_amounts, asset_amounts, None, None, None, None, None, None).await?;
2390 /// println!("Transaction sent with ID: {}", txid);
2391 /// # Ok(())
2392 /// # }
2393 /// ```
2394 #[allow(clippy::too_many_arguments, clippy::cognitive_complexity)]
2395 pub async fn sendmany(
2396 &self,
2397 wallet_name: &str,
2398 address_amounts: std::collections::HashMap<String, f64>,
2399 asset_amounts: std::collections::HashMap<String, String>,
2400 min_conf: Option<u32>,
2401 comment: Option<&str>,
2402 subtract_fee_from: Option<Vec<String>>,
2403 replaceable: Option<bool>,
2404 conf_target: Option<u32>,
2405 estimate_mode: Option<&str>,
2406 ) -> Result<String, AmpError> {
2407 tracing::debug!(
2408 "Sending to {} addresses using sendmany for wallet {}",
2409 address_amounts.len(),
2410 wallet_name
2411 );
2412
2413 // First load the wallet to ensure it's available
2414 self.load_wallet(wallet_name).await?;
2415
2416 // Elements sendmany parameters:
2417 // 1. dummy (empty string for compatibility)
2418 // 2. amounts (map of address -> amount)
2419 // 3. minconf (minimum confirmations, default 1)
2420 // 4. comment (optional comment)
2421 // 5. subtractfeefrom (array of addresses to subtract fee from)
2422 // 6. replaceable (boolean, default false)
2423 // 7. conf_target (confirmation target for fee estimation)
2424 // 8. estimate_mode (fee estimation mode)
2425 // 9. assetlabel (map of address -> asset_id for multi-asset sends)
2426 let params = serde_json::json!([
2427 "", // dummy (required for compatibility)
2428 address_amounts, // amounts map
2429 min_conf.unwrap_or(1), // minconf
2430 comment.unwrap_or(""), // comment
2431 subtract_fee_from.unwrap_or_default(), // subtractfeefrom
2432 replaceable.unwrap_or(false), // replaceable
2433 conf_target.unwrap_or(1), // conf_target
2434 estimate_mode.unwrap_or("UNSET"), // estimate_mode
2435 asset_amounts // assetlabel (asset map)
2436 ]);
2437
2438 // Use the wallet-specific RPC endpoint
2439 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
2440
2441 let request = RpcRequest {
2442 jsonrpc: "1.0".to_string(),
2443 id: "amp-client".to_string(),
2444 method: "sendmany".to_string(),
2445 params,
2446 };
2447
2448 tracing::debug!("Sendmany request parameters:");
2449 tracing::debug!(" wallet: {}", wallet_name);
2450 tracing::debug!(" address_amounts: {:?}", address_amounts);
2451 tracing::debug!(" asset_amounts: {:?}", asset_amounts);
2452
2453 let response = self
2454 .client
2455 .post(&wallet_url)
2456 .basic_auth(&self.username, Some(&self.password))
2457 .json(&request)
2458 .send()
2459 .await
2460 .map_err(|e| AmpError::rpc(format!("Failed to send sendmany RPC request: {e}")))?;
2461
2462 if !response.status().is_success() {
2463 let status = response.status();
2464 let error_body = response
2465 .text()
2466 .await
2467 .unwrap_or_else(|_| "Unable to read error body".to_string());
2468 return Err(AmpError::rpc(format!(
2469 "Sendmany RPC request failed with status: {status} - Body: {error_body}"
2470 )));
2471 }
2472
2473 let rpc_response: RpcResponse<String> = response
2474 .json()
2475 .await
2476 .map_err(|e| AmpError::rpc(format!("Failed to parse sendmany RPC response: {e}")))?;
2477
2478 if let Some(error) = rpc_response.error {
2479 return Err(AmpError::rpc(format!(
2480 "Sendmany RPC error: {} (code: {})",
2481 error.message, error.code
2482 )));
2483 }
2484
2485 let txid = rpc_response.result.unwrap_or_default();
2486 tracing::info!("Successfully sent transaction with sendmany: {}", txid);
2487 Ok(txid)
2488 }
2489
2490 /// Waits for blockchain confirmations with configurable timeout
2491 ///
2492 /// This method polls the blockchain every 15 seconds to check for transaction confirmations.
2493 /// It waits for a minimum number of confirmations (default 2) before returning successfully.
2494 /// The method includes a configurable timeout to prevent indefinite waiting.
2495 ///
2496 /// # Arguments
2497 /// * `txid` - The transaction ID to monitor for confirmations
2498 /// * `min_confirmations` - Minimum number of confirmations required (default: 2)
2499 /// * `timeout_minutes` - Timeout in minutes (default: 10)
2500 ///
2501 /// # Returns
2502 /// Returns the final `TransactionDetail` when sufficient confirmations are reached
2503 ///
2504 /// # Errors
2505 /// Returns `AmpError::Timeout` if the timeout is exceeded before confirmations are received
2506 /// Returns `AmpError::Rpc` if there are issues communicating with the Elements node
2507 ///
2508 /// # Examples
2509 /// ```no_run
2510 /// # use amp_rs::ElementsRpc;
2511 /// # #[tokio::main]
2512 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2513 /// let rpc = ElementsRpc::from_env()?;
2514 /// let tx_detail = rpc.wait_for_confirmations("abc123...", Some(2), Some(10)).await?;
2515 /// println!("Transaction confirmed with {} confirmations", tx_detail.confirmations);
2516 /// # Ok(())
2517 /// # }
2518 /// ```
2519 pub async fn wait_for_confirmations(
2520 &self,
2521 txid: &str,
2522 min_confirmations: Option<u32>,
2523 timeout_minutes: Option<u64>,
2524 ) -> Result<TransactionDetail, AmpError> {
2525 self.wait_for_confirmations_with_interval(txid, min_confirmations, timeout_minutes, None)
2526 .await
2527 }
2528
2529 /// Internal method for waiting for confirmations with configurable poll interval
2530 /// This is primarily used for testing to avoid long waits
2531 ///
2532 /// # Errors
2533 ///
2534 /// Returns an error if:
2535 /// - The timeout is exceeded before confirmations are received
2536 /// - There are issues communicating with the Elements node
2537 /// - The transaction cannot be found or is invalid
2538 #[allow(clippy::cognitive_complexity)]
2539 pub async fn wait_for_confirmations_with_interval(
2540 &self,
2541 txid: &str,
2542 min_confirmations: Option<u32>,
2543 timeout_minutes: Option<u64>,
2544 poll_interval_secs: Option<u64>,
2545 ) -> Result<TransactionDetail, AmpError> {
2546 let min_confirmations = min_confirmations.unwrap_or(2);
2547 let timeout_minutes = timeout_minutes.unwrap_or(10);
2548 let timeout_duration = if timeout_minutes == 0 {
2549 std::time::Duration::from_secs(3) // Minimum 3 seconds for testing
2550 } else {
2551 std::time::Duration::from_secs(timeout_minutes * 60)
2552 };
2553 let poll_interval = std::time::Duration::from_secs(poll_interval_secs.unwrap_or(15));
2554
2555 tracing::info!(
2556 "Starting confirmation monitoring for transaction {} (min_confirmations: {}, timeout: {} minutes)",
2557 txid,
2558 min_confirmations,
2559 timeout_minutes
2560 );
2561
2562 let start_time = std::time::Instant::now();
2563
2564 loop {
2565 // Check if we've exceeded the timeout
2566 if start_time.elapsed() >= timeout_duration {
2567 let error_msg = format!(
2568 "Timeout waiting for confirmations after {timeout_minutes} minutes. Transaction ID: {txid}. \
2569 You can retry confirmation by calling the confirmation API with this txid."
2570 );
2571 tracing::error!("{}", error_msg);
2572 return Err(AmpError::Timeout(error_msg));
2573 }
2574
2575 // Get current transaction details
2576 match self.get_transaction(txid).await {
2577 Ok(tx_detail) => {
2578 tracing::debug!(
2579 "Transaction {} has {} confirmations (need {})",
2580 txid,
2581 tx_detail.confirmations,
2582 min_confirmations
2583 );
2584
2585 if tx_detail.confirmations >= min_confirmations {
2586 tracing::info!(
2587 "Transaction {} confirmed with {} confirmations",
2588 txid,
2589 tx_detail.confirmations
2590 );
2591 return Ok(tx_detail);
2592 }
2593
2594 // Log progress every few polls to avoid spam
2595 if start_time.elapsed().as_secs() % 60 < 15 {
2596 tracing::info!(
2597 "Waiting for confirmations: {}/{} (elapsed: {}s)",
2598 tx_detail.confirmations,
2599 min_confirmations,
2600 start_time.elapsed().as_secs()
2601 );
2602 }
2603 }
2604 Err(e) => {
2605 tracing::warn!(
2606 "Failed to get transaction details for {}: {}. Retrying in {} seconds...",
2607 txid,
2608 e,
2609 poll_interval.as_secs()
2610 );
2611 // Continue polling even if individual calls fail, as the transaction
2612 // might not be visible immediately after broadcasting
2613 }
2614 }
2615
2616 // Wait before next poll
2617 tokio::time::sleep(poll_interval).await;
2618 }
2619 }
2620
2621 /// Reissues an asset using the Elements RPC reissueasset command
2622 ///
2623 /// This method reissues the specified amount of an asset.
2624 /// It requires the asset to be reissuable and the reissuance token to be available.
2625 ///
2626 /// # Arguments
2627 /// * `asset_id` - The asset ID (hex string) to reissue
2628 /// * `amount` - The amount to reissue (in satoshis for the asset)
2629 ///
2630 /// # Returns
2631 /// Returns a JSON value containing the reissuance output with txid and vin fields
2632 ///
2633 /// # Errors
2634 /// Returns an error if:
2635 /// - The asset ID is invalid
2636 /// - The asset is not reissuable
2637 /// - The reissuance token is not available
2638 /// - The RPC call fails
2639 ///
2640 /// # Examples
2641 /// ```no_run
2642 /// # use amp_rs::ElementsRpc;
2643 /// # #[tokio::main]
2644 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2645 /// let rpc = ElementsRpc::from_env()?;
2646 /// let asset_id = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d";
2647 /// let amount = 1000000.0; // 0.01 of an asset with 8 decimals
2648 /// let result = rpc.reissueasset(asset_id, amount).await?;
2649 /// println!("Reissuance txid: {}, vin: {}", result["txid"], result["vin"]);
2650 /// # Ok(())
2651 /// # }
2652 /// ```
2653 pub async fn reissueasset(
2654 &self,
2655 asset_id: &str,
2656 amount: f64,
2657 ) -> Result<serde_json::Value, AmpError> {
2658 tracing::debug!("Reissuing asset {} with amount {}", asset_id, amount);
2659
2660 let params = serde_json::json!([asset_id, amount]);
2661
2662 let result: serde_json::Value = self
2663 .rpc_call("reissueasset", params)
2664 .await
2665 .map_err(|e| {
2666 e.with_context(format!(
2667 "Failed to reissue asset {asset_id}. \
2668 Ensure the asset is reissuable and the reissuance token is available in the wallet."
2669 ))
2670 })?;
2671
2672 // Extract txid and vin from result for logging
2673 let txid = result
2674 .get("txid")
2675 .and_then(|v| v.as_str())
2676 .unwrap_or("unknown");
2677 let vin = result
2678 .get("vin")
2679 .and_then(serde_json::Value::as_u64)
2680 .unwrap_or(0);
2681
2682 tracing::info!("Reissuance transaction created: txid={}, vin={}", txid, vin);
2683
2684 Ok(result)
2685 }
2686
2687 /// Lists all issuances for a specific asset or all assets
2688 ///
2689 /// This method retrieves issuance information including initial issuances
2690 /// and reissuances. If an `asset_id` is provided, only issuances for that
2691 /// asset are returned.
2692 ///
2693 /// # Arguments
2694 /// * `asset_id` - Optional asset ID to filter issuances by. If None, returns all issuances
2695 ///
2696 /// # Returns
2697 /// Returns a vector of JSON values, each containing issuance information
2698 ///
2699 /// # Errors
2700 /// Returns an error if the RPC call fails
2701 ///
2702 /// # Examples
2703 /// ```no_run
2704 /// # use amp_rs::ElementsRpc;
2705 /// # #[tokio::main]
2706 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2707 /// let rpc = ElementsRpc::from_env()?;
2708 /// let asset_id = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d";
2709 /// let issuances = rpc.list_issuances(Some(asset_id)).await?;
2710 /// for issuance in issuances {
2711 /// if let Some(is_reissuance) = issuance.get("isreissuance").and_then(|v| v.as_bool()) {
2712 /// println!("Reissuance: {}", is_reissuance);
2713 /// }
2714 /// }
2715 /// # Ok(())
2716 /// # }
2717 /// ```
2718 pub async fn list_issuances(
2719 &self,
2720 asset_id: Option<&str>,
2721 ) -> Result<Vec<serde_json::Value>, AmpError> {
2722 tracing::debug!("Listing issuances for asset: {:?}", asset_id);
2723
2724 let params = asset_id.map_or_else(
2725 || serde_json::Value::Array(vec![]),
2726 |asset| serde_json::json!([asset]),
2727 );
2728
2729 let issuances: Vec<serde_json::Value> = self
2730 .rpc_call("listissuances", params)
2731 .await
2732 .map_err(|e| e.with_context("Failed to list issuances"))?;
2733
2734 tracing::debug!("Found {} issuances", issuances.len());
2735
2736 Ok(issuances)
2737 }
2738
2739 /// Destroys (burns) a specific amount of an asset
2740 ///
2741 /// This method calls the Elements node's `destroyamount` RPC to permanently
2742 /// remove (burn) a specified amount of an asset from the wallet.
2743 ///
2744 /// # Arguments
2745 /// * `asset_id` - The asset ID to burn
2746 /// * `amount` - The amount to burn (as a floating point number)
2747 ///
2748 /// # Returns
2749 /// Returns a JSON value containing the transaction ID of the burn transaction
2750 ///
2751 /// # Errors
2752 /// Returns an error if the RPC call fails or if insufficient balance exists
2753 ///
2754 /// # Examples
2755 /// ```no_run
2756 /// # use amp_rs::ElementsRpc;
2757 /// # #[tokio::main]
2758 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2759 /// let rpc = ElementsRpc::from_env()?;
2760 /// let asset_id = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d";
2761 /// let amount = 1000.0; // Burn 1000 units
2762 ///
2763 /// let txid = rpc.destroyamount(asset_id, amount).await?;
2764 /// println!("Burn transaction created: {}", txid);
2765 /// # Ok(())
2766 /// # }
2767 /// ```
2768 pub async fn destroyamount(&self, asset_id: &str, amount: f64) -> Result<String, AmpError> {
2769 tracing::debug!("Burning asset {} with amount {}", asset_id, amount);
2770
2771 let params = serde_json::json!([asset_id, amount]);
2772
2773 let result: String = self.rpc_call("destroyamount", params).await.map_err(|e| {
2774 e.with_context(format!(
2775 "Failed to burn asset {asset_id}. \
2776 Ensure sufficient balance exists in the wallet."
2777 ))
2778 })?;
2779
2780 tracing::info!("Burn transaction created: txid={}", result);
2781
2782 Ok(result)
2783 }
2784
2785 /// Gets the balance for all assets or a specific asset
2786 ///
2787 /// This method calls the Elements node's `getbalance` RPC to retrieve
2788 /// the wallet balance. If an `asset_id` is provided, returns the balance
2789 /// for that specific asset. If None, returns balances for all assets.
2790 ///
2791 /// # Arguments
2792 /// * `asset_id` - Optional asset ID to get balance for. If None, returns all asset balances
2793 ///
2794 /// # Returns
2795 /// Returns a JSON value containing asset balances (as a map of `asset_id` -> balance)
2796 ///
2797 /// # Errors
2798 /// Returns an error if the RPC call fails
2799 ///
2800 /// # Examples
2801 /// ```no_run
2802 /// # use amp_rs::ElementsRpc;
2803 /// # #[tokio::main]
2804 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2805 /// let rpc = ElementsRpc::from_env()?;
2806 /// let asset_id = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d";
2807 ///
2808 /// let balances = rpc.get_balance(None).await?;
2809 /// if let Some(balance) = balances.get(asset_id) {
2810 /// println!("Balance for asset {}: {}", asset_id, balance);
2811 /// }
2812 /// # Ok(())
2813 /// # }
2814 /// ```
2815 pub async fn get_balance(&self, asset_id: Option<&str>) -> Result<serde_json::Value, AmpError> {
2816 tracing::debug!("Getting balance for asset: {:?}", asset_id);
2817
2818 // getbalance RPC signature: getbalance ( "dummy" minconf include_watchonly )
2819 // We use "*" as the account, 0 minconf, false for include_watchonly
2820 let params = serde_json::json!(["*", 0, false]);
2821
2822 let balances: serde_json::Value = self
2823 .rpc_call("getbalance", params)
2824 .await
2825 .map_err(|e| e.with_context("Failed to get balance"))?;
2826
2827 // If asset_id is specified, return just that balance
2828 if let Some(asset_id) = asset_id {
2829 if let Some(balance) = balances.get(asset_id) {
2830 return Ok(balance.clone());
2831 }
2832 return Ok(serde_json::json!(0.0));
2833 }
2834
2835 tracing::debug!(
2836 "Retrieved balances for {} assets",
2837 balances.as_object().map_or(0, serde_json::Map::len)
2838 );
2839
2840 Ok(balances)
2841 }
2842
2843 /// Selects appropriate UTXOs to cover the required amount plus fees
2844 ///
2845 /// This method implements a simple UTXO selection algorithm that:
2846 /// 1. Filters UTXOs by asset ID and spendability
2847 /// 2. Sorts UTXOs by amount (largest first) for efficiency
2848 /// 3. Selects UTXOs until the target amount plus estimated fees is covered
2849 ///
2850 /// # Arguments
2851 /// * `asset_id` - The asset ID to select UTXOs for
2852 /// * `target_amount` - The total amount needed for distribution
2853 /// * `estimated_fee` - Estimated transaction fee in the same asset
2854 ///
2855 /// # Returns
2856 /// Returns a tuple of (`selected_utxos`, `total_selected_amount`)
2857 ///
2858 /// # Errors
2859 /// Returns an error if insufficient UTXOs are available or RPC calls fail
2860 ///
2861 /// # Examples
2862 /// ```no_run
2863 /// # use amp_rs::ElementsRpc;
2864 /// # #[tokio::main]
2865 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2866 /// let rpc = ElementsRpc::from_env()?;
2867 /// let (selected_utxos, total_amount) = rpc.select_utxos_for_amount(
2868 /// "wallet_name",
2869 /// "asset_id_hex",
2870 /// 150.0,
2871 /// 0.001
2872 /// ).await?;
2873 /// println!("Selected {} UTXOs totaling {}", selected_utxos.len(), total_amount);
2874 /// # Ok(())
2875 /// # }
2876 /// ```
2877 pub async fn select_utxos_for_amount(
2878 &self,
2879 wallet_name: &str,
2880 asset_id: &str,
2881 target_amount: f64,
2882 estimated_fee: f64,
2883 ) -> Result<(Vec<Unspent>, f64), AmpError> {
2884 tracing::debug!(
2885 "Selecting UTXOs for asset {} from wallet {} - target: {}, fee: {}",
2886 asset_id,
2887 wallet_name,
2888 target_amount,
2889 estimated_fee
2890 );
2891
2892 // Get all UTXOs for this asset from the specified wallet
2893 let mut utxos = self
2894 .list_unspent_for_wallet(wallet_name, Some(asset_id))
2895 .await?;
2896
2897 // Filter for spendable UTXOs only
2898 utxos.retain(|utxo| utxo.spendable && utxo.asset == asset_id);
2899
2900 if utxos.is_empty() {
2901 return Err(AmpError::validation(format!(
2902 "No spendable UTXOs found for asset {asset_id}. \
2903 This typically means:\n\
2904 1. The treasury address is not imported in the Elements node as a watch-only address\n\
2905 2. The asset issuance transaction hasn't been confirmed yet\n\
2906 3. The UTXOs have already been spent\n\
2907 \n\
2908 To fix this:\n\
2909 - Ensure the treasury address is imported: `elements-cli importaddress <treasury_address> treasury false`\n\
2910 - Wait for the asset issuance transaction to be confirmed\n\
2911 - Check that the treasury address matches the one used for asset issuance"
2912 )));
2913 }
2914
2915 // Sort UTXOs by amount (largest first) for efficient selection
2916 utxos.sort_by(|a, b| {
2917 b.amount
2918 .partial_cmp(&a.amount)
2919 .unwrap_or(std::cmp::Ordering::Equal)
2920 });
2921
2922 let required_amount = target_amount + estimated_fee;
2923 let mut selected_utxos = Vec::new();
2924 let mut total_selected = 0.0;
2925
2926 // Select UTXOs until we have enough to cover the required amount
2927 for utxo in utxos {
2928 selected_utxos.push(utxo.clone());
2929 total_selected += utxo.amount;
2930
2931 if total_selected >= required_amount {
2932 break;
2933 }
2934 }
2935
2936 // Check if we have sufficient funds
2937 if total_selected < required_amount {
2938 return Err(AmpError::validation(format!(
2939 "Insufficient UTXOs: need {required_amount}, have {total_selected} (target: {target_amount}, fee: {estimated_fee})"
2940 )));
2941 }
2942
2943 tracing::info!(
2944 "Selected {} UTXOs totaling {} for target {} + fee {}",
2945 selected_utxos.len(),
2946 total_selected,
2947 target_amount,
2948 estimated_fee
2949 );
2950
2951 Ok((selected_utxos, total_selected))
2952 }
2953
2954 /// Builds a raw transaction for asset distribution with proper change handling
2955 ///
2956 /// This method orchestrates the complete transaction building process:
2957 /// 1. Selects appropriate UTXOs using `select_utxos_for_amount`
2958 /// 2. Creates transaction inputs from selected UTXOs
2959 /// 3. Creates outputs for distribution addresses
2960 /// 4. Calculates and creates change output if necessary
2961 /// 5. Builds the raw transaction using `create_raw_transaction`
2962 ///
2963 /// # Arguments
2964 /// * `asset_id` - The asset ID being distributed
2965 /// * `address_amounts` - Map of recipient addresses to amounts
2966 /// * `change_address` - Address to send change to (if any)
2967 /// * `estimated_fee` - Estimated transaction fee
2968 ///
2969 /// # Returns
2970 /// Returns a tuple of (`raw_transaction_hex`, `selected_utxos`, `change_amount`)
2971 ///
2972 /// # Errors
2973 /// Returns an error if UTXO selection fails or transaction building fails
2974 ///
2975 /// # Examples
2976 /// ```no_run
2977 /// # use amp_rs::ElementsRpc;
2978 /// # use std::collections::HashMap;
2979 /// # #[tokio::main]
2980 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2981 /// let rpc = ElementsRpc::from_env()?;
2982 /// let mut address_amounts = HashMap::new();
2983 /// address_amounts.insert("address1".to_string(), 100.0);
2984 /// address_amounts.insert("address2".to_string(), 50.0);
2985 ///
2986 /// let (raw_tx, utxos, change) = rpc.build_distribution_transaction(
2987 /// "wallet_name",
2988 /// "asset_id_hex",
2989 /// address_amounts,
2990 /// "change_address",
2991 /// 0.001
2992 /// ).await?;
2993 /// println!("Built transaction with {} inputs, change: {}", utxos.len(), change);
2994 /// # Ok(())
2995 /// # }
2996 /// ```
2997 #[allow(clippy::cognitive_complexity)]
2998 #[allow(clippy::too_many_lines)]
2999 pub async fn build_distribution_transaction(
3000 &self,
3001 wallet_name: &str,
3002 asset_id: &str,
3003 address_amounts: std::collections::HashMap<String, f64>,
3004 change_address: &str,
3005 _estimated_fee: f64,
3006 ) -> Result<(String, Vec<Unspent>, f64), AmpError> {
3007 const DUST_THRESHOLD: f64 = 0.00001;
3008 const LBTC_ASSET_ID: &str =
3009 "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; // L-BTC on Liquid testnet
3010
3011 tracing::debug!(
3012 "Building distribution transaction for asset {} with {} outputs",
3013 asset_id,
3014 address_amounts.len()
3015 );
3016
3017 // Calculate total distribution amount
3018 let total_distribution: f64 = address_amounts.values().sum();
3019
3020 if total_distribution <= 0.0 {
3021 return Err(AmpError::validation(
3022 "Total distribution amount must be greater than zero".to_string(),
3023 ));
3024 }
3025
3026 // Select UTXOs to cover the distribution (custom asset)
3027 let (selected_asset_utxos, total_selected) = self
3028 .select_utxos_for_amount(wallet_name, asset_id, total_distribution, 0.0)
3029 .await?;
3030
3031 // Also select L-BTC UTXOs for transaction fees
3032 // Elements requires L-BTC inputs for fees even when distributing custom assets
3033 let min_lbtc_fee = 0.00001; // Minimum L-BTC needed for fees
3034 let (selected_lbtc_utxos, lbtc_total) = match self
3035 .select_utxos_for_amount(wallet_name, LBTC_ASSET_ID, 0.0, min_lbtc_fee)
3036 .await
3037 {
3038 Ok((utxos, total)) => {
3039 tracing::info!(
3040 "Selected {} L-BTC UTXOs totaling {} for fees",
3041 utxos.len(),
3042 total
3043 );
3044 (utxos, total)
3045 }
3046 Err(e) => {
3047 tracing::warn!(
3048 "Could not select L-BTC UTXOs for fees: {}. Transaction may fail.",
3049 e
3050 );
3051 (Vec::new(), 0.0)
3052 }
3053 };
3054
3055 // Combine custom asset UTXOs and L-BTC UTXOs
3056 let mut all_utxos = selected_asset_utxos.clone();
3057 all_utxos.extend(selected_lbtc_utxos.clone());
3058
3059 if selected_lbtc_utxos.is_empty() {
3060 tracing::warn!(
3061 "No L-BTC UTXOs selected for fees. Transaction may fail during broadcast."
3062 );
3063 } else {
3064 tracing::info!(
3065 "Transaction includes {} custom asset UTXOs and {} L-BTC UTXOs for fees",
3066 selected_asset_utxos.len(),
3067 selected_lbtc_utxos.len()
3068 );
3069 }
3070
3071 // Create transaction inputs from all selected UTXOs
3072 let inputs: Vec<TxInput> = all_utxos
3073 .iter()
3074 .map(|utxo| TxInput {
3075 txid: utxo.txid.clone(),
3076 vout: utxo.vout,
3077 sequence: None, // Use default sequence
3078 })
3079 .collect();
3080
3081 // Create outputs for distribution (custom asset)
3082 // We need to track outputs as a vector since we may have multiple outputs to the same address
3083 // (e.g., custom asset change + L-BTC change to the same change address)
3084 let mut output_list = Vec::new();
3085
3086 // Add distribution outputs (custom asset)
3087 for (address, amount) in &address_amounts {
3088 output_list.push((address.clone(), *amount, asset_id.to_string()));
3089 }
3090
3091 // Calculate change amount for custom asset (total selected - distribution)
3092 let asset_change_amount = total_selected - total_distribution;
3093
3094 // Add asset change output if there's a significant amount left
3095 if asset_change_amount > DUST_THRESHOLD {
3096 output_list.push((
3097 change_address.to_string(),
3098 asset_change_amount,
3099 asset_id.to_string(),
3100 ));
3101
3102 tracing::debug!(
3103 "Adding asset change output: {} {} to address {}",
3104 asset_change_amount,
3105 asset_id,
3106 change_address
3107 );
3108 } else if asset_change_amount > 0.0 {
3109 tracing::warn!(
3110 "Asset change amount {} is below dust threshold {}, will be lost",
3111 asset_change_amount,
3112 DUST_THRESHOLD
3113 );
3114 }
3115
3116 // Handle L-BTC change if we selected L-BTC UTXOs for fees
3117 // In Elements, the fee is implicit - it's the difference between L-BTC inputs and outputs
3118 // We should NOT subtract the fee from outputs; Elements calculates it automatically
3119 if !selected_lbtc_utxos.is_empty() {
3120 tracing::debug!(
3121 "L-BTC input total: {}, minimum fee needed: {}",
3122 lbtc_total,
3123 min_lbtc_fee
3124 );
3125
3126 // Check if we have enough L-BTC for the minimum fee
3127 if lbtc_total < min_lbtc_fee {
3128 return Err(AmpError::validation(format!(
3129 "Insufficient L-BTC for fees: have {lbtc_total}, need at least {min_lbtc_fee}"
3130 )));
3131 }
3132
3133 // For now, let's try NOT adding any L-BTC change output
3134 // and let Elements handle the fee automatically from the input/output difference
3135 tracing::info!(
3136 "Using L-BTC input {} for fees - no explicit L-BTC change output (Elements will handle fee automatically)",
3137 lbtc_total
3138 );
3139
3140 // Note: If this approach works, the entire L-BTC input will become the fee
3141 // If we need change, we'll need to figure out the correct way to handle it
3142 }
3143
3144 // For confidential addresses, we need to import them into the wallet first
3145 // so Elements knows about the blinding keys
3146 for address in address_amounts.keys() {
3147 if address.starts_with('v') {
3148 // Confidential address
3149 tracing::debug!("Importing confidential address into wallet: {}", address);
3150 if let Err(e) = self
3151 .import_address_to_wallet(wallet_name, address, None, false)
3152 .await
3153 {
3154 tracing::warn!("Failed to import confidential address {}: {}", address, e);
3155 // Continue anyway - the address might already be imported
3156 }
3157 }
3158 }
3159
3160 // Build the raw transaction using wallet-specific endpoint for confidential transactions
3161 // For confidential transactions, we need to use blindrawtransaction to properly handle blinding
3162 let raw_transaction = self
3163 .create_raw_transaction_with_outputs(wallet_name, inputs, output_list)
3164 .await
3165 .map_err(|e| {
3166 // Provide more helpful error message for the common L-BTC fee issue
3167 if e.to_string().contains("bad-txns-in-ne-out") || e.to_string().contains("value in != value out") {
3168 AmpError::validation(format!(
3169 "Transaction failed due to confidential transaction blinding mismatch. \
3170 This occurs when Elements creates blinding factors that don't match LWK's expectations. \
3171 To fix this:\n\
3172 1. Ensure the wallet has proper blinding keys for all addresses\n\
3173 2. Use blindrawtransaction before signing\n\
3174 3. Verify UTXO blinding factors match between Elements and LWK\n\
3175 4. Original error: {e}"
3176 ))
3177 } else {
3178 e.with_context("Failed to build distribution transaction")
3179 }
3180 })?;
3181
3182 // For confidential transactions, we need to blind the transaction properly
3183 // This ensures the blinding factors are compatible with LWK signing
3184 tracing::debug!("Blinding raw transaction for confidential asset distribution");
3185 let blinded_transaction = self
3186 .blind_raw_transaction(wallet_name, &raw_transaction)
3187 .await
3188 .map_err(|e| {
3189 tracing::warn!(
3190 "Failed to blind transaction, proceeding with unblinded: {}",
3191 e
3192 );
3193 // If blinding fails, we'll try to proceed with the unblinded transaction
3194 // This might work for some cases but could fail during broadcast
3195 e.with_context("Transaction blinding failed")
3196 })
3197 .unwrap_or_else(|_| {
3198 tracing::warn!("Using unblinded transaction - this may cause broadcast failures");
3199 raw_transaction.clone()
3200 });
3201
3202 tracing::info!(
3203 "Built distribution transaction: {} inputs, {} outputs, asset change: {}",
3204 all_utxos.len(),
3205 address_amounts.len() + usize::from(asset_change_amount > DUST_THRESHOLD),
3206 if asset_change_amount > DUST_THRESHOLD {
3207 asset_change_amount
3208 } else {
3209 0.0
3210 }
3211 );
3212
3213 Ok((blinded_transaction, all_utxos, asset_change_amount))
3214 }
3215
3216 /// Creates a raw transaction with multiple outputs that can handle multiple assets to the same address
3217 ///
3218 /// This method is similar to `create_raw_transaction_with_wallet` but handles the case where
3219 /// multiple outputs with different assets need to go to the same address (e.g., asset change + L-BTC change).
3220 ///
3221 /// # Arguments
3222 /// * `wallet_name` - Name of the Elements wallet to use
3223 /// * `inputs` - Vector of transaction inputs
3224 /// * `outputs` - Vector of (address, amount, `asset_id`) tuples
3225 ///
3226 /// # Returns
3227 /// Returns the raw transaction hex string
3228 #[allow(clippy::cognitive_complexity)]
3229 async fn create_raw_transaction_with_outputs(
3230 &self,
3231 wallet_name: &str,
3232 inputs: Vec<TxInput>,
3233 outputs: Vec<(String, f64, String)>, // (address, amount, asset_id)
3234 ) -> Result<String, AmpError> {
3235 tracing::debug!(
3236 "Creating raw transaction with wallet {} - {} inputs and {} outputs",
3237 wallet_name,
3238 inputs.len(),
3239 outputs.len()
3240 );
3241
3242 // First load the wallet to ensure it's available
3243 self.load_wallet(wallet_name).await?;
3244
3245 // Elements RPC createrawtransaction expects outputs as an array of objects
3246 // Each output object should contain both address, amount, and asset
3247 let mut outputs_array = Vec::new();
3248
3249 for (address, amount, asset_id) in &outputs {
3250 // Convert amount to string with proper precision for Elements
3251 let amount_str = format!("{amount:.8}");
3252
3253 outputs_array.push(serde_json::json!({
3254 address.clone(): amount_str,
3255 "asset": asset_id
3256 }));
3257 }
3258
3259 let params = serde_json::json!([
3260 inputs, // inputs as TxInput array
3261 outputs_array, // outputs as array of {address: amount, asset: id} objects
3262 0, // locktime (0 = no locktime)
3263 false, // replaceable (false = not replaceable)
3264 ]);
3265
3266 // Debug: Log the exact parameters being sent to createrawtransaction
3267 tracing::error!("createrawtransaction parameters (wallet-specific, corrected format):");
3268 tracing::error!(" wallet: {}", wallet_name);
3269 tracing::error!(
3270 " inputs: {}",
3271 serde_json::to_string_pretty(&inputs).unwrap_or_default()
3272 );
3273 tracing::error!(
3274 " outputs_array: {}",
3275 serde_json::to_string_pretty(&outputs_array).unwrap_or_default()
3276 );
3277
3278 // Use the wallet-specific RPC endpoint
3279 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3280
3281 let request = RpcRequest {
3282 jsonrpc: "1.0".to_string(),
3283 id: "amp-client".to_string(),
3284 method: "createrawtransaction".to_string(),
3285 params,
3286 };
3287
3288 let response = self
3289 .client
3290 .post(&wallet_url)
3291 .basic_auth(&self.username, Some(&self.password))
3292 .json(&request)
3293 .send()
3294 .await
3295 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3296
3297 if !response.status().is_success() {
3298 let status = response.status();
3299 let error_body = response
3300 .text()
3301 .await
3302 .unwrap_or_else(|_| "Unable to read error body".to_string());
3303 return Err(AmpError::rpc(format!(
3304 "RPC request failed with status: {status} - Body: {error_body}"
3305 )));
3306 }
3307
3308 let rpc_response: RpcResponse<String> = response
3309 .json()
3310 .await
3311 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
3312
3313 if let Some(error) = rpc_response.error {
3314 return Err(AmpError::rpc(format!(
3315 "RPC error creating raw transaction: {} (code: {})",
3316 error.message, error.code
3317 )));
3318 }
3319
3320 Ok(rpc_response.result.unwrap_or_default())
3321 }
3322
3323 /// Blinds a raw transaction for confidential transactions
3324 ///
3325 /// This method uses Elements' blindrawtransaction RPC to properly blind a transaction
3326 /// for confidential asset transfers. This is crucial for Liquid transactions to ensure
3327 /// the blinding factors are properly balanced.
3328 ///
3329 /// # Arguments
3330 /// * `wallet_name` - Name of the Elements wallet to use for blinding
3331 /// * `raw_transaction` - The raw transaction hex to blind
3332 ///
3333 /// # Returns
3334 /// Returns the blinded transaction hex string
3335 ///
3336 /// # Errors
3337 /// Returns an error if the RPC call fails or blinding is not possible
3338 pub async fn blind_raw_transaction(
3339 &self,
3340 wallet_name: &str,
3341 raw_transaction: &str,
3342 ) -> Result<String, AmpError> {
3343 tracing::debug!(
3344 "Blinding raw transaction for wallet {} - tx length: {} chars",
3345 wallet_name,
3346 raw_transaction.len()
3347 );
3348
3349 // First load the wallet to ensure it's available
3350 self.load_wallet(wallet_name).await?;
3351
3352 // Elements blindrawtransaction parameters:
3353 // 1. Raw transaction hex
3354 // 2. Input blinding data (can be empty array for auto-detection)
3355 // 3. Input amounts (can be empty array for auto-detection from UTXOs)
3356 // 4. Input assets (can be empty array for auto-detection from UTXOs)
3357 // 5. Input asset blinders (can be empty array for auto-detection)
3358 // 6. Input amount blinders (can be empty array for auto-detection)
3359 let params = serde_json::json!([
3360 raw_transaction, // Raw transaction hex
3361 [], // Input blinding data (empty for auto-detection)
3362 [], // Input amounts (empty for auto-detection)
3363 [], // Input assets (empty for auto-detection)
3364 [], // Input asset blinders (empty for auto-detection)
3365 [] // Input amount blinders (empty for auto-detection)
3366 ]);
3367
3368 // Use the wallet-specific RPC endpoint
3369 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3370
3371 let request = RpcRequest {
3372 jsonrpc: "1.0".to_string(),
3373 id: "amp-client".to_string(),
3374 method: "blindrawtransaction".to_string(),
3375 params,
3376 };
3377
3378 let response = self
3379 .client
3380 .post(&wallet_url)
3381 .basic_auth(&self.username, Some(&self.password))
3382 .json(&request)
3383 .send()
3384 .await
3385 .map_err(|e| {
3386 AmpError::rpc(format!("Failed to send blindrawtransaction request: {e}"))
3387 })?;
3388
3389 if !response.status().is_success() {
3390 let status = response.status();
3391 let error_body = response
3392 .text()
3393 .await
3394 .unwrap_or_else(|_| "Unable to read error body".to_string());
3395 return Err(AmpError::rpc(format!(
3396 "blindrawtransaction failed with status: {status} - Body: {error_body}"
3397 )));
3398 }
3399
3400 let rpc_response: RpcResponse<String> = response.json().await.map_err(|e| {
3401 AmpError::rpc(format!("Failed to parse blindrawtransaction response: {e}"))
3402 })?;
3403
3404 if let Some(error) = rpc_response.error {
3405 return Err(AmpError::rpc(format!(
3406 "RPC error blinding transaction: {} (code: {})",
3407 error.message, error.code
3408 )));
3409 }
3410
3411 let blinded_tx = rpc_response.result.unwrap_or_default();
3412
3413 tracing::info!(
3414 "Successfully blinded transaction - original: {} chars, blinded: {} chars",
3415 raw_transaction.len(),
3416 blinded_tx.len()
3417 );
3418
3419 Ok(blinded_tx)
3420 }
3421
3422 /// Signs a raw transaction using the provided signer callback
3423 ///
3424 /// This method integrates with the Signer trait to sign unsigned transactions.
3425 /// It handles the complete signing workflow including:
3426 /// 1. Validation of the unsigned transaction hex format
3427 /// 2. Calling the signer's `sign_transaction` method
3428 /// 3. Validation of the signed transaction format and structure
3429 /// 4. Proper error handling and context propagation
3430 ///
3431 /// # Arguments
3432 /// * `unsigned_tx_hex` - The unsigned transaction in hexadecimal format
3433 /// * `signer` - Implementation of the Signer trait for transaction signing
3434 ///
3435 /// # Returns
3436 /// Returns the signed transaction as a hex string
3437 ///
3438 /// # Errors
3439 /// Returns an error if:
3440 /// - The unsigned transaction hex is invalid or malformed
3441 /// - The signer fails to sign the transaction
3442 /// - The signed transaction format is invalid
3443 /// - Any validation checks fail
3444 ///
3445 /// # Examples
3446 /// ```no_run
3447 /// # use amp_rs::{ElementsRpc, signer::{Signer, LwkSoftwareSigner}};
3448 /// # #[tokio::main]
3449 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3450 /// let rpc = ElementsRpc::from_env()?;
3451 /// let (_, signer) = LwkSoftwareSigner::generate_new()?;
3452 /// let unsigned_tx = "020000000001..."; // Unsigned transaction hex
3453 /// let signed_tx = rpc.sign_transaction(unsigned_tx, &signer).await?;
3454 /// println!("Transaction signed successfully: {}", signed_tx);
3455 /// # Ok(())
3456 /// # }
3457 /// ```
3458 #[allow(clippy::cognitive_complexity)]
3459 pub async fn sign_transaction(
3460 &self,
3461 unsigned_tx_hex: &str,
3462 signer: &dyn crate::signer::Signer,
3463 ) -> Result<String, AmpError> {
3464 const MIN_TX_SIZE: usize = 10; // Minimum bytes for a valid transaction
3465
3466 tracing::debug!(
3467 "Signing transaction: {}...",
3468 &unsigned_tx_hex[..std::cmp::min(unsigned_tx_hex.len(), 64)]
3469 );
3470
3471 // Validate unsigned transaction hex format
3472 if unsigned_tx_hex.is_empty() {
3473 return Err(AmpError::validation(
3474 "Unsigned transaction hex cannot be empty".to_string(),
3475 ));
3476 }
3477
3478 // Check if hex string has valid format (even length, valid hex characters)
3479 if unsigned_tx_hex.len() % 2 != 0 {
3480 return Err(AmpError::validation(
3481 "Unsigned transaction hex must have even length".to_string(),
3482 ));
3483 }
3484
3485 // Validate hex characters
3486 if !unsigned_tx_hex.chars().all(|c| c.is_ascii_hexdigit()) {
3487 return Err(AmpError::validation(
3488 "Unsigned transaction contains invalid hex characters".to_string(),
3489 ));
3490 }
3491
3492 // Attempt to decode hex to validate transaction structure
3493 let tx_bytes = hex::decode(unsigned_tx_hex).map_err(|e| {
3494 AmpError::validation(format!("Failed to decode unsigned transaction hex: {e}"))
3495 })?;
3496
3497 tracing::debug!("Unsigned transaction validation passed, calling signer");
3498
3499 // Call the signer to sign the transaction
3500 let signed_tx_hex = signer
3501 .sign_transaction(unsigned_tx_hex)
3502 .await
3503 .map_err(|e| {
3504 tracing::error!("Transaction signing failed: {}", e);
3505 AmpError::Signer(e).with_context("Failed to sign transaction")
3506 })?;
3507
3508 tracing::debug!(
3509 "Signer returned signed transaction: {}...",
3510 &signed_tx_hex[..std::cmp::min(signed_tx_hex.len(), 64)]
3511 );
3512
3513 // Validate signed transaction format and structure
3514 if signed_tx_hex.is_empty() {
3515 return Err(AmpError::validation(
3516 "Signed transaction hex cannot be empty".to_string(),
3517 ));
3518 }
3519
3520 // Check if signed transaction has valid hex format
3521 if signed_tx_hex.len() % 2 != 0 {
3522 return Err(AmpError::validation(
3523 "Signed transaction hex must have even length".to_string(),
3524 ));
3525 }
3526
3527 // Validate hex characters in signed transaction
3528 if !signed_tx_hex.chars().all(|c| c.is_ascii_hexdigit()) {
3529 return Err(AmpError::validation(
3530 "Signed transaction contains invalid hex characters".to_string(),
3531 ));
3532 }
3533
3534 // Attempt to decode signed transaction to validate structure
3535 let signed_tx_bytes = hex::decode(&signed_tx_hex).map_err(|e| {
3536 AmpError::validation(format!("Failed to decode signed transaction hex: {e}"))
3537 })?;
3538
3539 // Basic validation: signed transaction should be at least as long as unsigned
3540 // (signatures add data, so signed tx should be larger or equal)
3541 if signed_tx_bytes.len() < tx_bytes.len() {
3542 return Err(AmpError::validation(
3543 "Signed transaction is shorter than unsigned transaction, which is invalid"
3544 .to_string(),
3545 ));
3546 }
3547
3548 // Additional validation: check that the transaction structure is reasonable
3549 // Minimum transaction size for Elements (very basic check)
3550 if signed_tx_bytes.len() < MIN_TX_SIZE {
3551 return Err(AmpError::validation(format!(
3552 "Signed transaction does not meet minimum size ({} bytes), minimum is {} bytes",
3553 signed_tx_bytes.len(),
3554 MIN_TX_SIZE
3555 )));
3556 }
3557
3558 tracing::info!(
3559 "Transaction signed successfully - unsigned: {} bytes, signed: {} bytes",
3560 tx_bytes.len(),
3561 signed_tx_bytes.len()
3562 );
3563
3564 Ok(signed_tx_hex)
3565 }
3566
3567 /// Signs and broadcasts a transaction in a single operation
3568 ///
3569 /// This is a convenience method that combines transaction signing and broadcasting.
3570 /// It performs the complete workflow of signing an unsigned transaction and
3571 /// immediately broadcasting it to the network.
3572 ///
3573 /// # Arguments
3574 /// * `unsigned_tx_hex` - The unsigned transaction in hexadecimal format
3575 /// * `signer` - Implementation of the Signer trait for transaction signing
3576 ///
3577 /// # Returns
3578 /// Returns the transaction ID of the broadcast transaction
3579 ///
3580 /// # Errors
3581 /// Returns an error if signing or broadcasting fails
3582 ///
3583 /// # Examples
3584 /// ```no_run
3585 /// # use amp_rs::{ElementsRpc, signer::{Signer, LwkSoftwareSigner}};
3586 /// # #[tokio::main]
3587 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3588 /// let rpc = ElementsRpc::from_env()?;
3589 /// let (_, signer) = LwkSoftwareSigner::generate_new()?;
3590 /// let unsigned_tx = "020000000001..."; // Unsigned transaction hex
3591 /// let txid = rpc.sign_and_broadcast_transaction(unsigned_tx, &signer).await?;
3592 /// println!("Transaction broadcast with ID: {}", txid);
3593 /// # Ok(())
3594 /// # }
3595 /// ```
3596 pub async fn sign_and_broadcast_transaction(
3597 &self,
3598 unsigned_tx_hex: &str,
3599 signer: &dyn crate::signer::Signer,
3600 ) -> Result<String, AmpError> {
3601 tracing::info!("Signing and broadcasting transaction");
3602
3603 // Sign the transaction
3604 let signed_tx_hex = self
3605 .sign_transaction(unsigned_tx_hex, signer)
3606 .await
3607 .map_err(|e| e.with_context("Failed during transaction signing phase"))?;
3608
3609 // Broadcast the signed transaction
3610 let txid = self
3611 .send_raw_transaction(&signed_tx_hex)
3612 .await
3613 .map_err(|e| e.with_context("Failed during transaction broadcast phase"))?;
3614
3615 tracing::info!("Successfully signed and broadcast transaction: {}", txid);
3616 Ok(txid)
3617 }
3618
3619 /// Signs and broadcasts a transaction with UTXO information for proper PSBT construction
3620 ///
3621 /// This method provides UTXO information to the signer for proper PSBT construction,
3622 /// which is required for confidential transactions where the signer needs to know
3623 /// the previous transaction outputs being spent.
3624 ///
3625 /// # Arguments
3626 /// * `unsigned_tx_hex` - The unsigned transaction in hexadecimal format
3627 /// * `utxos` - Vector of UTXOs being spent in the transaction
3628 /// * `signer` - Implementation of the Signer trait for transaction signing
3629 ///
3630 /// # Returns
3631 /// Returns the transaction ID of the broadcast transaction
3632 ///
3633 /// # Errors
3634 /// Returns an error if signing or broadcasting fails
3635 #[allow(clippy::cognitive_complexity)]
3636 pub async fn sign_and_broadcast_transaction_with_utxos(
3637 &self,
3638 unsigned_tx_hex: &str,
3639 utxos: &[Unspent],
3640 signer: &dyn crate::signer::Signer,
3641 ) -> Result<String, AmpError> {
3642 tracing::info!(
3643 "Signing and broadcasting transaction with {} UTXOs",
3644 utxos.len()
3645 );
3646
3647 // Try to use the enhanced signing method if the signer supports it
3648 let signed_tx_hex = if let Some(lwk_signer) = signer
3649 .as_any()
3650 .downcast_ref::<crate::signer::LwkSoftwareSigner>(
3651 ) {
3652 // Use the enhanced signing method with UTXO information
3653 tracing::debug!("Using LWK signer with UTXO information");
3654 lwk_signer
3655 .sign_transaction_with_utxos(unsigned_tx_hex, utxos)
3656 .await
3657 .map_err(|e| {
3658 AmpError::Signer(e)
3659 .with_context("Failed during enhanced transaction signing phase")
3660 })?
3661 } else {
3662 // Fall back to standard signing method
3663 tracing::debug!("Using standard signing method (no UTXO information)");
3664 self.sign_transaction(unsigned_tx_hex, signer)
3665 .await
3666 .map_err(|e| e.with_context("Failed during transaction signing phase"))?
3667 };
3668
3669 // Broadcast the signed transaction
3670 let txid = self
3671 .send_raw_transaction(&signed_tx_hex)
3672 .await
3673 .map_err(|e| e.with_context("Failed during transaction broadcast phase"))?;
3674
3675 tracing::info!("Successfully signed and broadcast transaction: {}", txid);
3676 Ok(txid)
3677 }
3678
3679 /// Collects change data from a confirmed transaction for distribution confirmation
3680 ///
3681 /// This method queries the Elements node to find change UTXOs from a specific transaction
3682 /// that belong to the specified asset. It's used after a distribution transaction is
3683 /// confirmed to collect the change outputs for the final confirmation API call.
3684 ///
3685 /// # Arguments
3686 /// * `asset_id` - The asset ID to filter change UTXOs for
3687 /// * `txid` - The transaction ID to filter change UTXOs from
3688 ///
3689 /// # Returns
3690 /// Returns a vector of Unspent UTXOs that represent change outputs from the transaction.
3691 /// Returns an empty vector if no change outputs exist for the specified asset and transaction.
3692 ///
3693 /// # Errors
3694 /// Returns an error if the RPC call fails or if there are issues querying the Elements node
3695 ///
3696 /// # Examples
3697 /// ```no_run
3698 /// # use amp_rs::ElementsRpc;
3699 /// # #[tokio::main]
3700 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3701 /// let rpc = ElementsRpc::from_env()?;
3702 /// let change_data = rpc.collect_change_data(
3703 /// "asset_id_hex",
3704 /// "transaction_id_hex",
3705 /// &rpc,
3706 /// "wallet_name"
3707 /// ).await?;
3708 ///
3709 /// if change_data.is_empty() {
3710 /// println!("No change outputs found for this transaction");
3711 /// } else {
3712 /// println!("Found {} change outputs", change_data.len());
3713 /// }
3714 /// # Ok(())
3715 /// # }
3716 /// ```
3717 #[allow(clippy::cognitive_complexity)]
3718 pub async fn collect_change_data(
3719 &self,
3720 asset_id: &str,
3721 txid: &str,
3722 node_rpc: &Self,
3723 wallet_name: &str,
3724 ) -> Result<Vec<Unspent>, AmpError> {
3725 tracing::debug!(
3726 "Collecting change data for asset {} from transaction {}",
3727 asset_id,
3728 txid
3729 );
3730
3731 // Use the raw listunspent RPC call to get full blinding information
3732 // This is essential for confidential transactions as the AMP API requires
3733 // both amountblinder and assetblinder fields
3734 let all_utxos = node_rpc
3735 .list_unspent_with_blinding_data(wallet_name)
3736 .await
3737 .map_err(|e| {
3738 e.with_context(
3739 "Failed to query unspent outputs with blinding data for change data collection",
3740 )
3741 })?;
3742
3743 // Filter UTXOs to only include those from the specified transaction
3744 let change_utxos: Vec<Unspent> = all_utxos
3745 .into_iter()
3746 .filter(|utxo| {
3747 // Match UTXOs that:
3748 // 1. Come from the specified transaction (txid matches)
3749 // 2. Are for the correct asset
3750 // 3. Are spendable
3751 utxo.txid == txid && utxo.asset == asset_id && utxo.spendable
3752 })
3753 .collect();
3754
3755 tracing::info!(
3756 "Collected {} change UTXOs for asset {} from transaction {}",
3757 change_utxos.len(),
3758 asset_id,
3759 txid
3760 );
3761
3762 // Log details of found change UTXOs for debugging
3763 for (index, utxo) in change_utxos.iter().enumerate() {
3764 tracing::debug!(
3765 "Change UTXO {}: txid={}, vout={}, amount={}, asset={}, amountblinder={:?}, assetblinder={:?}",
3766 index + 1,
3767 utxo.txid,
3768 utxo.vout,
3769 utxo.amount,
3770 utxo.asset,
3771 utxo.amountblinder,
3772 utxo.assetblinder
3773 );
3774 }
3775
3776 // Handle the case where no change outputs exist
3777 if change_utxos.is_empty() {
3778 tracing::info!(
3779 "No change outputs found for asset {} in transaction {} - this is normal if all funds were distributed",
3780 asset_id,
3781 txid
3782 );
3783 }
3784
3785 Ok(change_utxos)
3786 }
3787
3788 /// Lists unspent outputs with full blinding data for confidential transactions
3789 ///
3790 /// This method calls the raw `listunspent` RPC to get complete UTXO information
3791 /// including blinding data (amountblinder and assetblinder) which is required
3792 /// for confidential transaction confirmation with the AMP API.
3793 ///
3794 /// # Arguments
3795 /// * `wallet_name` - Name of the Elements wallet to query
3796 ///
3797 /// # Returns
3798 /// Returns a vector of `Unspent` structs with complete blinding information
3799 ///
3800 /// # Errors
3801 /// Returns an error if the RPC call fails
3802 ///
3803 /// # Examples
3804 /// ```no_run
3805 /// # use amp_rs::ElementsRpc;
3806 /// # #[tokio::main]
3807 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3808 /// let rpc = ElementsRpc::from_env()?;
3809 /// let utxos = rpc.list_unspent_with_blinding_data("wallet_name").await?;
3810 /// for utxo in utxos {
3811 /// println!("UTXO: {} with blinders: {:?}, {:?}",
3812 /// utxo.txid, utxo.amountblinder, utxo.assetblinder);
3813 /// }
3814 /// # Ok(())
3815 /// # }
3816 /// ```
3817 pub async fn list_unspent_with_blinding_data(
3818 &self,
3819 wallet_name: &str,
3820 ) -> Result<Vec<Unspent>, AmpError> {
3821 tracing::debug!(
3822 "Listing unspent outputs with blinding data for wallet: {}",
3823 wallet_name
3824 );
3825
3826 // First load the wallet to ensure it's available
3827 self.load_wallet(wallet_name).await?;
3828
3829 // Call listunspent with parameters to get all UTXOs
3830 // Parameters: minconf, maxconf, addresses, include_unsafe, query_options
3831 let params = serde_json::json!([
3832 0, // minconf: include unconfirmed
3833 9_999_999, // maxconf: include all confirmed
3834 [], // addresses: empty array means all addresses
3835 true, // include_unsafe: include unconfirmed transactions
3836 {} // query_options: empty object for default options
3837 ]);
3838
3839 // Use the wallet-specific RPC endpoint
3840 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3841
3842 let request = RpcRequest {
3843 jsonrpc: "1.0".to_string(),
3844 id: "amp-client".to_string(),
3845 method: "listunspent".to_string(),
3846 params,
3847 };
3848
3849 let response = self
3850 .client
3851 .post(&wallet_url)
3852 .basic_auth(&self.username, Some(&self.password))
3853 .json(&request)
3854 .send()
3855 .await
3856 .map_err(|e| AmpError::rpc(format!("Failed to send listunspent RPC request: {e}")))?;
3857
3858 if !response.status().is_success() {
3859 let status = response.status();
3860 let error_body = response
3861 .text()
3862 .await
3863 .unwrap_or_else(|_| "Unable to read error body".to_string());
3864 return Err(AmpError::rpc(format!(
3865 "Listunspent RPC request failed with status: {status} - Body: {error_body}"
3866 )));
3867 }
3868
3869 let rpc_response: RpcResponse<Vec<Unspent>> = response
3870 .json()
3871 .await
3872 .map_err(|e| AmpError::rpc(format!("Failed to parse listunspent RPC response: {e}")))?;
3873
3874 if let Some(error) = rpc_response.error {
3875 return Err(AmpError::rpc(format!(
3876 "Listunspent RPC error: {} (code: {})",
3877 error.message, error.code
3878 )));
3879 }
3880
3881 let utxos = rpc_response.result.unwrap_or_default();
3882 tracing::info!(
3883 "Retrieved {} UTXOs with blinding data from wallet {}",
3884 utxos.len(),
3885 wallet_name
3886 );
3887
3888 Ok(utxos)
3889 }
3890
3891 /// Creates a standard wallet in Elements (Elements-first approach)
3892 ///
3893 /// This method creates a new standard wallet in the Elements node that can generate
3894 /// addresses and private keys. This is part of the Elements-first approach where
3895 /// we create the wallet in Elements first, then export keys to LWK.
3896 ///
3897 /// # Arguments
3898 /// * `wallet_name` - Name for the new wallet
3899 ///
3900 /// # Errors
3901 /// Returns an error if the RPC call fails
3902 ///
3903 /// # Examples
3904 /// ```no_run
3905 /// # use amp_rs::ElementsRpc;
3906 /// # #[tokio::main]
3907 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3908 /// let rpc = ElementsRpc::from_env()?;
3909 /// rpc.create_elements_wallet("test_wallet").await?;
3910 /// # Ok(())
3911 /// # }
3912 /// ```
3913 pub async fn create_elements_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
3914 let params = serde_json::json!([wallet_name]);
3915
3916 let _result: serde_json::Value = self.rpc_call("createwallet", params).await?;
3917
3918 tracing::info!("Successfully created Elements wallet: {}", wallet_name);
3919 Ok(())
3920 }
3921
3922 /// Get a new address from an Elements wallet
3923 ///
3924 /// This method requests a new address from the specified Elements wallet.
3925 /// The address will be generated by Elements and can be used for receiving funds.
3926 /// Defaults to native segwit (bech32) addresses for optimal compatibility.
3927 ///
3928 /// # Arguments
3929 /// * `wallet_name` - Name of the wallet to get address from
3930 /// * `address_type` - Optional address type ("bech32", "legacy", "p2sh-segwit"). Defaults to "bech32"
3931 ///
3932 /// # Errors
3933 /// Returns an error if the RPC call fails or the response format is unexpected
3934 ///
3935 /// # Examples
3936 /// ```no_run
3937 /// # use amp_rs::ElementsRpc;
3938 /// # #[tokio::main]
3939 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
3940 /// let rpc = ElementsRpc::from_env()?;
3941 ///
3942 /// // Generate native segwit address (default)
3943 /// let address = rpc.get_new_address("test_wallet", None).await?;
3944 ///
3945 /// // Or explicitly request native segwit
3946 /// let bech32_address = rpc.get_new_address("test_wallet", Some("bech32")).await?;
3947 ///
3948 /// println!("Native segwit address: {}", address);
3949 /// # Ok(())
3950 /// # }
3951 /// ```
3952 pub async fn get_new_address(
3953 &self,
3954 wallet_name: &str,
3955 address_type: Option<&str>,
3956 ) -> Result<String, AmpError> {
3957 // First load the wallet to ensure it's available
3958 self.load_wallet(wallet_name).await?;
3959
3960 // Set default to native segwit (bech32) for Elements
3961 let addr_type = address_type.unwrap_or("bech32");
3962
3963 // For Elements, we need to use the correct parameters for getnewaddress
3964 // getnewaddress [label] [address_type]
3965 let params = serde_json::json!(["", addr_type]);
3966
3967 // Create RPC request for getnewaddress
3968 let request = RpcRequest {
3969 jsonrpc: "1.0".to_string(),
3970 id: "amp-client".to_string(),
3971 method: "getnewaddress".to_string(),
3972 params,
3973 };
3974
3975 // Use the wallet-specific RPC endpoint
3976 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
3977
3978 let response = self
3979 .client
3980 .post(&wallet_url)
3981 .basic_auth(&self.username, Some(&self.password))
3982 .json(&request)
3983 .send()
3984 .await
3985 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
3986
3987 if !response.status().is_success() {
3988 let status = response.status();
3989 let error_body = response
3990 .text()
3991 .await
3992 .unwrap_or_else(|_| "Unable to read error body".to_string());
3993 return Err(AmpError::rpc(format!(
3994 "RPC request failed with status: {status} - Body: {error_body}"
3995 )));
3996 }
3997
3998 let rpc_response: RpcResponse<serde_json::Value> = response
3999 .json()
4000 .await
4001 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4002
4003 if let Some(error) = rpc_response.error {
4004 return Err(AmpError::rpc(format!(
4005 "RPC error getting new address: {} (code: {})",
4006 error.message, error.code
4007 )));
4008 }
4009
4010 if let Some(result) = rpc_response.result {
4011 if let Some(address) = result.as_str() {
4012 tracing::info!("Generated new {} address: {}", addr_type, address);
4013 return Ok(address.to_string());
4014 }
4015 }
4016
4017 Err(AmpError::rpc(format!(
4018 "Failed to get new address from wallet '{wallet_name}': unexpected response format"
4019 )))
4020 }
4021
4022 /// Get the confidential version of an address from Elements wallet
4023 ///
4024 /// This method takes a regular (unconfidential) address and returns its confidential
4025 /// counterpart, which includes blinding keys for confidential transactions.
4026 ///
4027 /// # Arguments
4028 ///
4029 /// * `wallet_name` - Name of the Elements wallet
4030 /// * `address` - The unconfidential address to get info for
4031 ///
4032 /// # Returns
4033 ///
4034 /// Returns the confidential address string
4035 ///
4036 /// # Example
4037 ///
4038 /// ```no_run
4039 /// # use amp_rs::ElementsRpc;
4040 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
4041 /// let rpc = ElementsRpc::from_env()?;
4042 /// let unconfidential_address = "tex1q...";
4043 /// // Note: This would need to be called in an async context
4044 /// // let confidential_address = rpc.get_confidential_address("test_wallet", unconfidential_address).await?;
4045 /// // println!("Confidential address: {}", confidential_address);
4046 /// # Ok(())
4047 /// # }
4048 /// ```
4049 /// Gets the confidential address for a given unconfidential address from a wallet
4050 ///
4051 /// # Errors
4052 /// Returns an error if the RPC call fails or the response format is unexpected
4053 pub async fn get_confidential_address(
4054 &self,
4055 wallet_name: &str,
4056 address: &str,
4057 ) -> Result<String, AmpError> {
4058 // First load the wallet to ensure it's available
4059 self.load_wallet(wallet_name).await?;
4060
4061 let params = serde_json::json!([address]);
4062
4063 // Create RPC request for getaddressinfo
4064 let request = RpcRequest {
4065 jsonrpc: "1.0".to_string(),
4066 id: "amp-client".to_string(),
4067 method: "getaddressinfo".to_string(),
4068 params,
4069 };
4070
4071 // Use the wallet-specific RPC endpoint
4072 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4073
4074 let response = self
4075 .client
4076 .post(&wallet_url)
4077 .basic_auth(&self.username, Some(&self.password))
4078 .json(&request)
4079 .send()
4080 .await
4081 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4082
4083 if !response.status().is_success() {
4084 let status = response.status();
4085 let error_body = response
4086 .text()
4087 .await
4088 .unwrap_or_else(|_| "Unable to read error body".to_string());
4089 return Err(AmpError::rpc(format!(
4090 "RPC request failed with status: {status} - Body: {error_body}"
4091 )));
4092 }
4093
4094 let rpc_response: RpcResponse<serde_json::Value> = response
4095 .json()
4096 .await
4097 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4098
4099 if let Some(error) = rpc_response.error {
4100 return Err(AmpError::rpc(format!(
4101 "RPC error getting address info: {} (code: {})",
4102 error.message, error.code
4103 )));
4104 }
4105
4106 if let Some(result) = rpc_response.result {
4107 if let Some(confidential_address) = result.get("confidential").and_then(|v| v.as_str())
4108 {
4109 tracing::info!("Retrieved confidential address for: {}", address);
4110 return Ok(confidential_address.to_string());
4111 }
4112 }
4113
4114 Err(AmpError::rpc(format!(
4115 "Failed to get confidential address for '{address}': unexpected response format"
4116 )))
4117 }
4118
4119 /// Get the private key for an address from Elements wallet
4120 ///
4121 /// This method exports the private key for a specific address from the Elements wallet.
4122 /// The private key can then be imported into LWK for signing.
4123 ///
4124 /// Note: This is a simplified implementation that returns a placeholder private key.
4125 /// For production use, implement proper wallet-specific RPC calls.
4126 ///
4127 /// # Arguments
4128 /// * `wallet_name` - Name of the wallet containing the address
4129 /// * `address` - The address to get the private key for
4130 ///
4131 /// # Errors
4132 /// Returns an error if the RPC call fails
4133 ///
4134 /// # Examples
4135 /// ```no_run
4136 /// # use amp_rs::ElementsRpc;
4137 /// # #[tokio::main]
4138 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4139 /// let rpc = ElementsRpc::from_env()?;
4140 /// let address = rpc.get_new_address("test_wallet", None).await?;
4141 /// let private_key = rpc.dump_private_key("test_wallet", &address).await?;
4142 /// println!("Private key: {}", private_key);
4143 /// # Ok(())
4144 /// # }
4145 /// ```
4146 pub async fn dump_private_key(
4147 &self,
4148 wallet_name: &str,
4149 address: &str,
4150 ) -> Result<String, AmpError> {
4151 // First load the wallet to ensure it's available
4152 self.load_wallet(wallet_name).await?;
4153
4154 let params = serde_json::json!([address]);
4155
4156 // Create RPC request for dumpprivkey
4157 let request = RpcRequest {
4158 jsonrpc: "1.0".to_string(),
4159 id: "amp-client".to_string(),
4160 method: "dumpprivkey".to_string(),
4161 params,
4162 };
4163
4164 // Use the wallet-specific RPC endpoint
4165 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4166
4167 let response = self
4168 .client
4169 .post(&wallet_url)
4170 .basic_auth(&self.username, Some(&self.password))
4171 .json(&request)
4172 .send()
4173 .await
4174 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4175
4176 if !response.status().is_success() {
4177 return Err(AmpError::rpc(format!(
4178 "RPC request failed with status: {}",
4179 response.status()
4180 )));
4181 }
4182
4183 let rpc_response: RpcResponse<serde_json::Value> = response
4184 .json()
4185 .await
4186 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4187
4188 if let Some(error) = rpc_response.error {
4189 return Err(AmpError::rpc(format!(
4190 "RPC error dumping private key: {} (code: {})",
4191 error.message, error.code
4192 )));
4193 }
4194
4195 if let Some(result) = rpc_response.result {
4196 if let Some(private_key) = result.as_str() {
4197 tracing::info!("Successfully exported private key for address: {}", address);
4198 return Ok(private_key.to_string());
4199 }
4200 }
4201
4202 Err(AmpError::rpc(format!(
4203 "Failed to dump private key for address '{address}': unexpected response format"
4204 )))
4205 }
4206
4207 /// Creates a descriptor wallet in Elements
4208 ///
4209 /// # Arguments
4210 /// * `wallet_name` - Name for the new wallet
4211 ///
4212 /// # Errors
4213 /// Returns an error if the RPC call fails
4214 ///
4215 /// # Examples
4216 /// ```no_run
4217 /// # use amp_rs::ElementsRpc;
4218 /// # #[tokio::main]
4219 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4220 /// let rpc = ElementsRpc::from_env()?;
4221 /// rpc.create_descriptor_wallet("test_wallet").await?;
4222 /// # Ok(())
4223 /// # }
4224 /// ```
4225 pub async fn create_descriptor_wallet(&self, wallet_name: &str) -> Result<(), AmpError> {
4226 let params = serde_json::json!([wallet_name, true]); // true enables descriptors
4227
4228 let _result: serde_json::Value = self.rpc_call("createwallet", params).await?;
4229
4230 tracing::info!("Successfully created descriptor wallet: {}", wallet_name);
4231 Ok(())
4232 }
4233
4234 /// Imports a single descriptor into an Elements wallet
4235 ///
4236 /// This method imports a descriptor that enables the wallet to scan and recognize
4237 /// addresses/UTXOs from a mnemonic. For LWK descriptors with `<0;1>/*` format,
4238 /// a single descriptor covers both receive and change addresses.
4239 ///
4240 /// # Arguments
4241 /// * `wallet_name` - Name of the wallet to import descriptor into
4242 /// * `descriptor` - The descriptor to import
4243 ///
4244 /// # Errors
4245 /// Returns an error if the RPC call fails
4246 ///
4247 /// # Examples
4248 /// ```no_run
4249 /// # use amp_rs::ElementsRpc;
4250 /// # #[tokio::main]
4251 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4252 /// let rpc = ElementsRpc::from_env()?;
4253 /// let descriptor = "ct(slip77(...),elwpkh([...]/84h/1h/0h]tpub.../<0;1>/*))#checksum";
4254 /// rpc.import_descriptor("test_wallet", descriptor).await?;
4255 /// # Ok(())
4256 /// # }
4257 /// ```
4258 pub async fn import_descriptor(
4259 &self,
4260 wallet_name: &str,
4261 descriptor: &str,
4262 ) -> Result<(), AmpError> {
4263 tracing::info!("Importing descriptor into wallet: {}", wallet_name);
4264 tracing::debug!("Descriptor: {}", descriptor);
4265
4266 let descriptors = serde_json::json!([
4267 {
4268 "desc": descriptor,
4269 "timestamp": "now",
4270 "active": true,
4271 "internal": false // For LWK descriptors with <0;1>/*, this covers both chains
4272 }
4273 ]);
4274
4275 // Use -rpcwallet parameter to specify the wallet
4276 let request = RpcRequest {
4277 jsonrpc: "1.0".to_string(),
4278 id: "amp-client".to_string(),
4279 method: "importdescriptors".to_string(),
4280 params: descriptors,
4281 };
4282
4283 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4284
4285 let response = self
4286 .client
4287 .post(&wallet_url)
4288 .basic_auth(&self.username, Some(&self.password))
4289 .json(&request)
4290 .send()
4291 .await
4292 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4293
4294 if !response.status().is_success() {
4295 return Err(AmpError::rpc(format!(
4296 "RPC request failed with status: {}",
4297 response.status()
4298 )));
4299 }
4300
4301 let rpc_response: RpcResponse<serde_json::Value> = response
4302 .json()
4303 .await
4304 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4305
4306 if let Some(error) = rpc_response.error {
4307 return Err(AmpError::rpc(format!(
4308 "RPC error {}: {}",
4309 error.code, error.message
4310 )));
4311 }
4312
4313 let result = rpc_response
4314 .result
4315 .ok_or_else(|| AmpError::rpc("RPC response missing result field".to_string()))?;
4316
4317 // Check if descriptor was imported successfully
4318 if let Some(results) = result.as_array() {
4319 if let Some(result) = results.first() {
4320 if let Some(success) = result.get("success").and_then(serde_json::Value::as_bool) {
4321 if !success {
4322 let error_msg = result
4323 .get("error")
4324 .and_then(|e| e.get("message"))
4325 .and_then(|m| m.as_str())
4326 .unwrap_or("Unknown error");
4327 return Err(AmpError::rpc(format!(
4328 "Failed to import descriptor: {error_msg}"
4329 )));
4330 }
4331 } else {
4332 return Err(AmpError::rpc(format!(
4333 "Invalid response format for descriptor import: {result:?}"
4334 )));
4335 }
4336 }
4337 } else {
4338 return Err(AmpError::rpc(format!(
4339 "Invalid response format: expected array, got {result:?}"
4340 )));
4341 }
4342
4343 tracing::info!(
4344 "Successfully imported descriptor into wallet: {}",
4345 wallet_name
4346 );
4347 Ok(())
4348 }
4349
4350 /// Imports descriptors into an Elements wallet (legacy method for compatibility)
4351 ///
4352 /// This method imports descriptors that enable the wallet to scan and recognize
4353 /// addresses/UTXOs from a mnemonic. If both descriptors are the same (as with LWK
4354 /// descriptors using `<0;1>/*` format), only one descriptor is imported.
4355 ///
4356 /// # Arguments
4357 /// * `wallet_name` - Name of the wallet to import descriptors into
4358 /// * `receive_descriptor` - The receive descriptor
4359 /// * `change_descriptor` - The change descriptor
4360 ///
4361 /// # Errors
4362 /// Returns an error if the RPC call fails
4363 ///
4364 /// # Examples
4365 /// ```no_run
4366 /// # use amp_rs::ElementsRpc;
4367 /// # #[tokio::main]
4368 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4369 /// let rpc = ElementsRpc::from_env()?;
4370 /// let descriptor = "ct(slip77(...),elwpkh([...]/84h/1h/0h]tpub.../<0;1>/*))#checksum";
4371 /// rpc.import_descriptors("test_wallet", descriptor, descriptor).await?;
4372 /// # Ok(())
4373 /// # }
4374 /// ```
4375 #[allow(clippy::cognitive_complexity)]
4376 pub async fn import_descriptors(
4377 &self,
4378 wallet_name: &str,
4379 receive_descriptor: &str,
4380 change_descriptor: &str,
4381 ) -> Result<(), AmpError> {
4382 // If both descriptors are the same (LWK case), import only once
4383 if receive_descriptor == change_descriptor {
4384 return self
4385 .import_descriptor(wallet_name, receive_descriptor)
4386 .await;
4387 }
4388
4389 tracing::info!(
4390 "Importing separate receive and change descriptors into wallet: {}",
4391 wallet_name
4392 );
4393 tracing::debug!("Receive descriptor: {}", receive_descriptor);
4394 tracing::debug!("Change descriptor: {}", change_descriptor);
4395
4396 let descriptors = serde_json::json!([
4397 {
4398 "desc": receive_descriptor,
4399 "timestamp": "now",
4400 "active": true,
4401 "internal": false
4402 },
4403 {
4404 "desc": change_descriptor,
4405 "timestamp": "now",
4406 "active": true,
4407 "internal": true
4408 }
4409 ]);
4410
4411 // Use -rpcwallet parameter to specify the wallet
4412 let request = RpcRequest {
4413 jsonrpc: "1.0".to_string(),
4414 id: "amp-client".to_string(),
4415 method: "importdescriptors".to_string(),
4416 params: descriptors,
4417 };
4418
4419 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4420
4421 let response = self
4422 .client
4423 .post(&wallet_url)
4424 .basic_auth(&self.username, Some(&self.password))
4425 .json(&request)
4426 .send()
4427 .await
4428 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4429
4430 if !response.status().is_success() {
4431 return Err(AmpError::rpc(format!(
4432 "RPC request failed with status: {}",
4433 response.status()
4434 )));
4435 }
4436
4437 let rpc_response: RpcResponse<serde_json::Value> = response
4438 .json()
4439 .await
4440 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4441
4442 if let Some(error) = rpc_response.error {
4443 return Err(AmpError::rpc(format!(
4444 "RPC error {}: {}",
4445 error.code, error.message
4446 )));
4447 }
4448
4449 let result = rpc_response
4450 .result
4451 .ok_or_else(|| AmpError::rpc("RPC response missing result field".to_string()))?;
4452
4453 // Check if both descriptors were imported successfully
4454 if let Some(results) = result.as_array() {
4455 for (i, result) in results.iter().enumerate() {
4456 if let Some(success) = result.get("success").and_then(serde_json::Value::as_bool) {
4457 if !success {
4458 let desc_type = if i == 0 { "receive" } else { "change" };
4459 let error_msg = result
4460 .get("error")
4461 .and_then(|e| e.get("message"))
4462 .and_then(|m| m.as_str())
4463 .unwrap_or("Unknown error");
4464 return Err(AmpError::rpc(format!(
4465 "Failed to import {desc_type} descriptor: {error_msg}"
4466 )));
4467 }
4468 } else {
4469 return Err(AmpError::rpc(format!(
4470 "Invalid response format for descriptor import: {result:?}"
4471 )));
4472 }
4473 }
4474 } else {
4475 return Err(AmpError::rpc(format!(
4476 "Invalid response format: expected array, got {result:?}"
4477 )));
4478 }
4479
4480 tracing::info!(
4481 "Successfully imported descriptors into wallet: {}",
4482 wallet_name
4483 );
4484 Ok(())
4485 }
4486
4487 /// Sets up a wallet with descriptors from a mnemonic
4488 ///
4489 /// This is a convenience method that combines wallet creation and descriptor import.
4490 /// It creates a descriptor wallet and imports the receive and change descriptors
4491 /// generated from the provided mnemonic.
4492 ///
4493 /// # Arguments
4494 /// * `wallet_name` - Name for the new wallet
4495 /// * `receive_descriptor` - The receive descriptor (external chain /0/*)
4496 /// * `change_descriptor` - The change descriptor (internal chain /1/*)
4497 ///
4498 /// # Errors
4499 /// Returns an error if wallet creation or descriptor import fails
4500 ///
4501 /// # Examples
4502 /// ```no_run
4503 /// # use amp_rs::ElementsRpc;
4504 /// # #[tokio::main]
4505 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4506 /// let rpc = ElementsRpc::from_env()?;
4507 /// let receive_desc = "wpkh([d34db33f/84h/1h/0h]xprv.../0/*)#checksum";
4508 /// let change_desc = "wpkh([d34db33f/84h/1h/0h]xprv.../1/*)#checksum";
4509 /// rpc.setup_wallet_with_descriptors("test_wallet", receive_desc, change_desc).await?;
4510 /// # Ok(())
4511 /// # }
4512 /// ```
4513 #[allow(clippy::cognitive_complexity)]
4514 pub async fn setup_wallet_with_descriptors(
4515 &self,
4516 wallet_name: &str,
4517 receive_descriptor: &str,
4518 change_descriptor: &str,
4519 ) -> Result<(), AmpError> {
4520 tracing::info!("Setting up wallet with descriptors: {}", wallet_name);
4521
4522 // Try to create the wallet (may fail if it already exists)
4523 match self.create_descriptor_wallet(wallet_name).await {
4524 Ok(()) => {
4525 tracing::info!("Created new descriptor wallet: {}", wallet_name);
4526 }
4527 Err(e) => {
4528 let error_msg = e.to_string();
4529 if error_msg.contains("already exists")
4530 || error_msg.contains("Database already exists")
4531 {
4532 tracing::info!(
4533 "Wallet {} already exists, proceeding with descriptor import",
4534 wallet_name
4535 );
4536 } else {
4537 return Err(e);
4538 }
4539 }
4540 }
4541
4542 // Import the descriptors
4543 self.import_descriptors(wallet_name, receive_descriptor, change_descriptor)
4544 .await?;
4545
4546 tracing::info!(
4547 "Successfully set up wallet with descriptors: {}",
4548 wallet_name
4549 );
4550 Ok(())
4551 }
4552
4553 /// Exports a wallet to a file using dumpwallet RPC
4554 ///
4555 /// # Arguments
4556 /// * `wallet_name` - Name of the wallet to export
4557 /// * `file_path` - Path where the wallet dump file will be created
4558 ///
4559 /// # Errors
4560 /// Returns an error if the RPC call fails or the wallet cannot be exported
4561 ///
4562 /// # Examples
4563 /// ```no_run
4564 /// # use amp_rs::ElementsRpc;
4565 /// # #[tokio::main]
4566 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4567 /// let rpc = ElementsRpc::from_env()?;
4568 /// rpc.dump_wallet("my_wallet", "/tmp/wallet_export.dat").await?;
4569 /// # Ok(())
4570 /// # }
4571 /// ```
4572 pub async fn dump_wallet(&self, wallet_name: &str, file_path: &str) -> Result<(), AmpError> {
4573 // First load the wallet to ensure it's available
4574 self.load_wallet(wallet_name).await?;
4575
4576 let params = serde_json::json!([file_path]);
4577
4578 // Create RPC request for dumpwallet
4579 let request = RpcRequest {
4580 jsonrpc: "1.0".to_string(),
4581 id: "amp-client".to_string(),
4582 method: "dumpwallet".to_string(),
4583 params,
4584 };
4585
4586 // Use the wallet-specific RPC endpoint
4587 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4588
4589 let response = self
4590 .client
4591 .post(&wallet_url)
4592 .basic_auth(&self.username, Some(&self.password))
4593 .json(&request)
4594 .send()
4595 .await
4596 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4597
4598 if !response.status().is_success() {
4599 return Err(AmpError::rpc(format!(
4600 "RPC request failed with status: {}",
4601 response.status()
4602 )));
4603 }
4604
4605 let rpc_response: RpcResponse<serde_json::Value> = response
4606 .json()
4607 .await
4608 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4609
4610 if let Some(error) = rpc_response.error {
4611 return Err(AmpError::rpc(format!(
4612 "RPC error dumping wallet: {} (code: {})",
4613 error.message, error.code
4614 )));
4615 }
4616
4617 tracing::info!(
4618 "Successfully exported wallet {} to {}",
4619 wallet_name,
4620 file_path
4621 );
4622 Ok(())
4623 }
4624
4625 /// Imports a wallet from a file using importwallet RPC
4626 ///
4627 /// # Arguments
4628 /// * `wallet_name` - Name of the wallet to import into
4629 /// * `file_path` - Path to the wallet dump file to import
4630 ///
4631 /// # Errors
4632 /// Returns an error if the RPC call fails or the wallet cannot be imported
4633 ///
4634 /// # Examples
4635 /// ```no_run
4636 /// # use amp_rs::ElementsRpc;
4637 /// # #[tokio::main]
4638 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4639 /// let rpc = ElementsRpc::from_env()?;
4640 /// rpc.import_wallet("my_wallet", "/tmp/wallet_export.dat").await?;
4641 /// # Ok(())
4642 /// # }
4643 /// ```
4644 pub async fn import_wallet(&self, wallet_name: &str, file_path: &str) -> Result<(), AmpError> {
4645 // First load the wallet to ensure it's available
4646 self.load_wallet(wallet_name).await?;
4647
4648 let params = serde_json::json!([file_path]);
4649
4650 // Create RPC request for importwallet
4651 let request = RpcRequest {
4652 jsonrpc: "1.0".to_string(),
4653 id: "amp-client".to_string(),
4654 method: "importwallet".to_string(),
4655 params,
4656 };
4657
4658 // Use the wallet-specific RPC endpoint
4659 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4660
4661 let response = self
4662 .client
4663 .post(&wallet_url)
4664 .basic_auth(&self.username, Some(&self.password))
4665 .json(&request)
4666 .send()
4667 .await
4668 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4669
4670 if !response.status().is_success() {
4671 return Err(AmpError::rpc(format!(
4672 "RPC request failed with status: {}",
4673 response.status()
4674 )));
4675 }
4676
4677 let rpc_response: RpcResponse<serde_json::Value> = response
4678 .json()
4679 .await
4680 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4681
4682 if let Some(error) = rpc_response.error {
4683 return Err(AmpError::rpc(format!(
4684 "RPC error importing wallet: {} (code: {})",
4685 error.message, error.code
4686 )));
4687 }
4688
4689 tracing::info!(
4690 "Successfully imported wallet {} from {}",
4691 wallet_name,
4692 file_path
4693 );
4694 Ok(())
4695 }
4696
4697 /// Exports a blinding key for a confidential address using dumpblindingkey RPC
4698 ///
4699 /// # Arguments
4700 /// * `wallet_name` - Name of the wallet containing the address
4701 /// * `address` - The confidential address to export the blinding key for
4702 ///
4703 /// # Errors
4704 /// Returns an error if the RPC call fails or the address doesn't have a blinding key
4705 ///
4706 /// # Examples
4707 /// ```no_run
4708 /// # use amp_rs::ElementsRpc;
4709 /// # #[tokio::main]
4710 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4711 /// let rpc = ElementsRpc::from_env()?;
4712 /// let key = rpc.dump_blinding_key("my_wallet", "VTpz...").await?;
4713 /// println!("Blinding key: {}", key);
4714 /// # Ok(())
4715 /// # }
4716 /// ```
4717 pub async fn dump_blinding_key(
4718 &self,
4719 wallet_name: &str,
4720 address: &str,
4721 ) -> Result<String, AmpError> {
4722 // First load the wallet to ensure it's available
4723 self.load_wallet(wallet_name).await?;
4724
4725 let params = serde_json::json!([address]);
4726
4727 // Create RPC request for dumpblindingkey
4728 let request = RpcRequest {
4729 jsonrpc: "1.0".to_string(),
4730 id: "amp-client".to_string(),
4731 method: "dumpblindingkey".to_string(),
4732 params,
4733 };
4734
4735 // Use the wallet-specific RPC endpoint
4736 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4737
4738 let response = self
4739 .client
4740 .post(&wallet_url)
4741 .basic_auth(&self.username, Some(&self.password))
4742 .json(&request)
4743 .send()
4744 .await
4745 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4746
4747 if !response.status().is_success() {
4748 return Err(AmpError::rpc(format!(
4749 "RPC request failed with status: {}",
4750 response.status()
4751 )));
4752 }
4753
4754 let rpc_response: RpcResponse<serde_json::Value> = response
4755 .json()
4756 .await
4757 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4758
4759 if let Some(error) = rpc_response.error {
4760 return Err(AmpError::rpc(format!(
4761 "RPC error dumping blinding key: {} (code: {})",
4762 error.message, error.code
4763 )));
4764 }
4765
4766 if let Some(result) = rpc_response.result {
4767 if let Some(blinding_key) = result.as_str() {
4768 tracing::info!(
4769 "Successfully exported blinding key for address: {}",
4770 address
4771 );
4772 return Ok(blinding_key.to_string());
4773 }
4774 }
4775
4776 Err(AmpError::rpc(format!(
4777 "Failed to dump blinding key for address '{address}': unexpected response format"
4778 )))
4779 }
4780
4781 /// Imports a blinding key for a confidential address using importblindingkey RPC
4782 ///
4783 /// # Arguments
4784 /// * `wallet_name` - Name of the wallet to import the blinding key into
4785 /// * `address` - The confidential address to import the blinding key for
4786 /// * `blinding_key` - The blinding key to import
4787 ///
4788 /// # Errors
4789 /// Returns an error if the RPC call fails or the blinding key cannot be imported
4790 ///
4791 /// # Examples
4792 /// ```no_run
4793 /// # use amp_rs::ElementsRpc;
4794 /// # #[tokio::main]
4795 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4796 /// let rpc = ElementsRpc::from_env()?;
4797 /// rpc.import_blinding_key("my_wallet", "VTpz...", "blinding_key_hex").await?;
4798 /// # Ok(())
4799 /// # }
4800 /// ```
4801 pub async fn import_blinding_key(
4802 &self,
4803 wallet_name: &str,
4804 address: &str,
4805 blinding_key: &str,
4806 ) -> Result<(), AmpError> {
4807 // First load the wallet to ensure it's available
4808 self.load_wallet(wallet_name).await?;
4809
4810 let params = serde_json::json!([address, blinding_key]);
4811
4812 // Create RPC request for importblindingkey
4813 let request = RpcRequest {
4814 jsonrpc: "1.0".to_string(),
4815 id: "amp-client".to_string(),
4816 method: "importblindingkey".to_string(),
4817 params,
4818 };
4819
4820 // Use the wallet-specific RPC endpoint
4821 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4822
4823 let response = self
4824 .client
4825 .post(&wallet_url)
4826 .basic_auth(&self.username, Some(&self.password))
4827 .json(&request)
4828 .send()
4829 .await
4830 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4831
4832 if !response.status().is_success() {
4833 return Err(AmpError::rpc(format!(
4834 "RPC request failed with status: {}",
4835 response.status()
4836 )));
4837 }
4838
4839 let rpc_response: RpcResponse<serde_json::Value> = response
4840 .json()
4841 .await
4842 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4843
4844 if let Some(error) = rpc_response.error {
4845 return Err(AmpError::rpc(format!(
4846 "RPC error importing blinding key: {} (code: {})",
4847 error.message, error.code
4848 )));
4849 }
4850
4851 tracing::info!(
4852 "Successfully imported blinding key for address: {}",
4853 address
4854 );
4855 Ok(())
4856 }
4857
4858 /// Gets wallet information using getwalletinfo RPC
4859 ///
4860 /// # Arguments
4861 /// * `wallet_name` - Name of the wallet to get information for
4862 ///
4863 /// # Errors
4864 /// Returns an error if the RPC call fails
4865 ///
4866 /// # Examples
4867 /// ```no_run
4868 /// # use amp_rs::ElementsRpc;
4869 /// # #[tokio::main]
4870 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4871 /// let rpc = ElementsRpc::from_env()?;
4872 /// let info = rpc.get_wallet_info("my_wallet").await?;
4873 /// println!("Wallet info: {:?}", info);
4874 /// # Ok(())
4875 /// # }
4876 /// ```
4877 pub async fn get_wallet_info(&self, wallet_name: &str) -> Result<serde_json::Value, AmpError> {
4878 // First load the wallet to ensure it's available
4879 self.load_wallet(wallet_name).await?;
4880
4881 let params = serde_json::json!([]);
4882
4883 // Create RPC request for getwalletinfo
4884 let request = RpcRequest {
4885 jsonrpc: "1.0".to_string(),
4886 id: "amp-client".to_string(),
4887 method: "getwalletinfo".to_string(),
4888 params,
4889 };
4890
4891 // Use the wallet-specific RPC endpoint
4892 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4893
4894 let response = self
4895 .client
4896 .post(&wallet_url)
4897 .basic_auth(&self.username, Some(&self.password))
4898 .json(&request)
4899 .send()
4900 .await
4901 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4902
4903 if !response.status().is_success() {
4904 return Err(AmpError::rpc(format!(
4905 "RPC request failed with status: {}",
4906 response.status()
4907 )));
4908 }
4909
4910 let rpc_response: RpcResponse<serde_json::Value> = response
4911 .json()
4912 .await
4913 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4914
4915 if let Some(error) = rpc_response.error {
4916 return Err(AmpError::rpc(format!(
4917 "RPC error getting wallet info: {} (code: {})",
4918 error.message, error.code
4919 )));
4920 }
4921
4922 if let Some(result) = rpc_response.result {
4923 tracing::info!("Successfully retrieved wallet info for: {}", wallet_name);
4924 return Ok(result);
4925 }
4926
4927 Err(AmpError::rpc(format!(
4928 "Failed to get wallet info for '{wallet_name}': unexpected response format"
4929 )))
4930 }
4931
4932 /// Gets the unconfidential address for a confidential address
4933 ///
4934 /// # Arguments
4935 /// * `wallet_name` - Name of the wallet
4936 /// * `confidential_address` - The confidential address to convert
4937 ///
4938 /// # Errors
4939 /// Returns an error if the RPC call fails
4940 ///
4941 /// # Examples
4942 /// ```no_run
4943 /// # use amp_rs::ElementsRpc;
4944 /// # #[tokio::main]
4945 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
4946 /// let rpc = ElementsRpc::from_env()?;
4947 /// let unconf = rpc.get_unconfidential_address("my_wallet", "VTpz...").await?;
4948 /// println!("Unconfidential address: {}", unconf);
4949 /// # Ok(())
4950 /// # }
4951 /// ```
4952 pub async fn get_unconfidential_address(
4953 &self,
4954 wallet_name: &str,
4955 confidential_address: &str,
4956 ) -> Result<String, AmpError> {
4957 // First load the wallet to ensure it's available
4958 self.load_wallet(wallet_name).await?;
4959
4960 let params = serde_json::json!([confidential_address]);
4961
4962 // Create RPC request for getunconfidentialaddress
4963 let request = RpcRequest {
4964 jsonrpc: "1.0".to_string(),
4965 id: "amp-client".to_string(),
4966 method: "getunconfidentialaddress".to_string(),
4967 params,
4968 };
4969
4970 // Use the wallet-specific RPC endpoint
4971 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
4972
4973 let response = self
4974 .client
4975 .post(&wallet_url)
4976 .basic_auth(&self.username, Some(&self.password))
4977 .json(&request)
4978 .send()
4979 .await
4980 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
4981
4982 if !response.status().is_success() {
4983 return Err(AmpError::rpc(format!(
4984 "RPC request failed with status: {}",
4985 response.status()
4986 )));
4987 }
4988
4989 let rpc_response: RpcResponse<serde_json::Value> = response
4990 .json()
4991 .await
4992 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
4993
4994 if let Some(error) = rpc_response.error {
4995 return Err(AmpError::rpc(format!(
4996 "RPC error getting unconfidential address: {} (code: {})",
4997 error.message, error.code
4998 )));
4999 }
5000
5001 if let Some(result) = rpc_response.result {
5002 if let Some(address) = result.as_str() {
5003 tracing::info!(
5004 "Successfully got unconfidential address for: {}",
5005 confidential_address
5006 );
5007 return Ok(address.to_string());
5008 }
5009 }
5010
5011 Err(AmpError::rpc(format!(
5012 "Failed to get unconfidential address for '{confidential_address}': unexpected response format"
5013 )))
5014 }
5015
5016 /// Imports a private key into the wallet using importprivkey RPC
5017 ///
5018 /// # Arguments
5019 /// * `wallet_name` - Name of the wallet to import into
5020 /// * `private_key` - The private key in WIF format
5021 /// * `label` - Optional label for the address
5022 /// * `rescan` - Whether to rescan the blockchain for transactions
5023 ///
5024 /// # Errors
5025 /// Returns an error if the RPC call fails
5026 ///
5027 /// # Examples
5028 /// ```no_run
5029 /// # use amp_rs::ElementsRpc;
5030 /// # #[tokio::main]
5031 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
5032 /// let rpc = ElementsRpc::from_env()?;
5033 /// rpc.import_private_key("my_wallet", "cT1...", Some("my_address"), Some(false)).await?;
5034 /// # Ok(())
5035 /// # }
5036 /// ```
5037 pub async fn import_private_key(
5038 &self,
5039 wallet_name: &str,
5040 private_key: &str,
5041 label: Option<&str>,
5042 rescan: Option<bool>,
5043 ) -> Result<(), AmpError> {
5044 // First load the wallet to ensure it's available
5045 self.load_wallet(wallet_name).await?;
5046
5047 let params = serde_json::json!([private_key, label.unwrap_or(""), rescan.unwrap_or(false)]);
5048
5049 // Create RPC request for importprivkey
5050 let request = RpcRequest {
5051 jsonrpc: "1.0".to_string(),
5052 id: "amp-client".to_string(),
5053 method: "importprivkey".to_string(),
5054 params,
5055 };
5056
5057 // Use the wallet-specific RPC endpoint
5058 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
5059
5060 let response = self
5061 .client
5062 .post(&wallet_url)
5063 .basic_auth(&self.username, Some(&self.password))
5064 .json(&request)
5065 .send()
5066 .await
5067 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
5068
5069 if !response.status().is_success() {
5070 return Err(AmpError::rpc(format!(
5071 "RPC request failed with status: {}",
5072 response.status()
5073 )));
5074 }
5075
5076 let rpc_response: RpcResponse<serde_json::Value> = response
5077 .json()
5078 .await
5079 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
5080
5081 if let Some(error) = rpc_response.error {
5082 return Err(AmpError::rpc(format!(
5083 "RPC error importing private key: {} (code: {})",
5084 error.message, error.code
5085 )));
5086 }
5087
5088 tracing::info!("Successfully imported private key");
5089 Ok(())
5090 }
5091
5092 /// Lists all descriptors in a wallet using listdescriptors RPC
5093 ///
5094 /// # Arguments
5095 /// * `wallet_name` - Name of the wallet
5096 /// * `private_keys` - Whether to include private keys in the output
5097 ///
5098 /// # Errors
5099 /// Returns an error if the RPC call fails
5100 ///
5101 /// # Examples
5102 /// ```no_run
5103 /// # use amp_rs::ElementsRpc;
5104 /// # #[tokio::main]
5105 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
5106 /// let rpc = ElementsRpc::from_env()?;
5107 /// let descriptors = rpc.list_descriptors("my_wallet", Some(true)).await?;
5108 /// for desc in descriptors {
5109 /// println!("Descriptor: {}", desc);
5110 /// }
5111 /// # Ok(())
5112 /// # }
5113 /// ```
5114 pub async fn list_descriptors(
5115 &self,
5116 wallet_name: &str,
5117 private_keys: Option<bool>,
5118 ) -> Result<Vec<String>, AmpError> {
5119 // First load the wallet to ensure it's available
5120 self.load_wallet(wallet_name).await?;
5121
5122 let params = serde_json::json!([private_keys.unwrap_or(false)]);
5123
5124 // Create RPC request for listdescriptors
5125 let request = RpcRequest {
5126 jsonrpc: "1.0".to_string(),
5127 id: "amp-client".to_string(),
5128 method: "listdescriptors".to_string(),
5129 params,
5130 };
5131
5132 // Use the wallet-specific RPC endpoint
5133 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
5134
5135 let response = self
5136 .client
5137 .post(&wallet_url)
5138 .basic_auth(&self.username, Some(&self.password))
5139 .json(&request)
5140 .send()
5141 .await
5142 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
5143
5144 if !response.status().is_success() {
5145 return Err(AmpError::rpc(format!(
5146 "RPC request failed with status: {}",
5147 response.status()
5148 )));
5149 }
5150
5151 let rpc_response: RpcResponse<serde_json::Value> = response
5152 .json()
5153 .await
5154 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
5155
5156 if let Some(error) = rpc_response.error {
5157 return Err(AmpError::rpc(format!(
5158 "RPC error listing descriptors: {} (code: {})",
5159 error.message, error.code
5160 )));
5161 }
5162
5163 if let Some(result) = rpc_response.result {
5164 // Result has a "descriptors" array with objects containing "desc" field
5165 if let Some(descriptors_array) = result.get("descriptors").and_then(|v| v.as_array()) {
5166 let descriptors: Vec<String> = descriptors_array
5167 .iter()
5168 .filter_map(|d| d.get("desc").and_then(|v| v.as_str()).map(String::from))
5169 .collect();
5170 tracing::info!(
5171 "Successfully retrieved {} descriptors for wallet: {}",
5172 descriptors.len(),
5173 wallet_name
5174 );
5175 return Ok(descriptors);
5176 }
5177 }
5178
5179 Ok(Vec::new())
5180 }
5181
5182 /// Gets all addresses in a wallet by label using getaddressesbylabel RPC
5183 ///
5184 /// # Arguments
5185 /// * `wallet_name` - Name of the wallet
5186 /// * `label` - Label to filter by (empty string for all addresses)
5187 ///
5188 /// # Errors
5189 /// Returns an error if the RPC call fails
5190 ///
5191 /// # Examples
5192 /// ```no_run
5193 /// # use amp_rs::ElementsRpc;
5194 /// # #[tokio::main]
5195 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
5196 /// let rpc = ElementsRpc::from_env()?;
5197 /// let addresses = rpc.get_addresses_by_label("my_wallet", "").await?;
5198 /// for addr in addresses {
5199 /// println!("Address: {}", addr);
5200 /// }
5201 /// # Ok(())
5202 /// # }
5203 /// ```
5204 pub async fn get_addresses_by_label(
5205 &self,
5206 wallet_name: &str,
5207 label: &str,
5208 ) -> Result<Vec<String>, AmpError> {
5209 // First load the wallet to ensure it's available
5210 self.load_wallet(wallet_name).await?;
5211
5212 let params = serde_json::json!([label]);
5213
5214 // Create RPC request for getaddressesbylabel
5215 let request = RpcRequest {
5216 jsonrpc: "1.0".to_string(),
5217 id: "amp-client".to_string(),
5218 method: "getaddressesbylabel".to_string(),
5219 params,
5220 };
5221
5222 // Use the wallet-specific RPC endpoint
5223 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
5224
5225 let response = self
5226 .client
5227 .post(&wallet_url)
5228 .basic_auth(&self.username, Some(&self.password))
5229 .json(&request)
5230 .send()
5231 .await
5232 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
5233
5234 if !response.status().is_success() {
5235 return Err(AmpError::rpc(format!(
5236 "RPC request failed with status: {}",
5237 response.status()
5238 )));
5239 }
5240
5241 let rpc_response: RpcResponse<serde_json::Value> = response
5242 .json()
5243 .await
5244 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
5245
5246 if let Some(error) = rpc_response.error {
5247 return Err(AmpError::rpc(format!(
5248 "RPC error getting addresses by label: {} (code: {})",
5249 error.message, error.code
5250 )));
5251 }
5252
5253 if let Some(result) = rpc_response.result {
5254 // Result is an object with addresses as keys
5255 if let Some(obj) = result.as_object() {
5256 let addresses: Vec<String> = obj.keys().cloned().collect();
5257 tracing::info!(
5258 "Successfully retrieved {} addresses for wallet: {}",
5259 addresses.len(),
5260 wallet_name
5261 );
5262 return Ok(addresses);
5263 }
5264 }
5265
5266 Ok(Vec::new())
5267 }
5268
5269 /// Lists addresses that have received transactions using listreceivedbyaddress RPC
5270 ///
5271 /// # Arguments
5272 /// * `wallet_name` - Name of the wallet to list addresses for
5273 /// * `min_conf` - Minimum number of confirmations (0 for unconfirmed)
5274 /// * `include_empty` - Whether to include addresses that haven't received payments
5275 ///
5276 /// # Errors
5277 /// Returns an error if the RPC call fails
5278 ///
5279 /// # Examples
5280 /// ```no_run
5281 /// # use amp_rs::ElementsRpc;
5282 /// # #[tokio::main]
5283 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
5284 /// let rpc = ElementsRpc::from_env()?;
5285 /// let addresses = rpc.list_received_by_address("my_wallet", 0, true).await?;
5286 /// for addr in addresses {
5287 /// println!("Address: {:?}", addr);
5288 /// }
5289 /// # Ok(())
5290 /// # }
5291 /// ```
5292 pub async fn list_received_by_address(
5293 &self,
5294 wallet_name: &str,
5295 min_conf: u32,
5296 include_empty: bool,
5297 ) -> Result<Vec<ReceivedByAddress>, AmpError> {
5298 // First load the wallet to ensure it's available
5299 self.load_wallet(wallet_name).await?;
5300
5301 let params = serde_json::json!([min_conf, include_empty]);
5302
5303 // Create RPC request for listreceivedbyaddress
5304 let request = RpcRequest {
5305 jsonrpc: "1.0".to_string(),
5306 id: "amp-client".to_string(),
5307 method: "listreceivedbyaddress".to_string(),
5308 params,
5309 };
5310
5311 // Use the wallet-specific RPC endpoint
5312 let wallet_url = format!("{}/wallet/{}", self.base_url, wallet_name);
5313
5314 let response = self
5315 .client
5316 .post(&wallet_url)
5317 .basic_auth(&self.username, Some(&self.password))
5318 .json(&request)
5319 .send()
5320 .await
5321 .map_err(|e| AmpError::rpc(format!("Failed to send RPC request: {e}")))?;
5322
5323 if !response.status().is_success() {
5324 return Err(AmpError::rpc(format!(
5325 "RPC request failed with status: {}",
5326 response.status()
5327 )));
5328 }
5329
5330 let rpc_response: RpcResponse<Vec<ReceivedByAddress>> = response
5331 .json()
5332 .await
5333 .map_err(|e| AmpError::rpc(format!("Failed to parse RPC response: {e}")))?;
5334
5335 if let Some(error) = rpc_response.error {
5336 return Err(AmpError::rpc(format!(
5337 "RPC error listing received by address: {} (code: {})",
5338 error.message, error.code
5339 )));
5340 }
5341
5342 if let Some(result) = rpc_response.result {
5343 tracing::info!(
5344 "Successfully listed {} addresses for wallet: {}",
5345 result.len(),
5346 wallet_name
5347 );
5348 return Ok(result);
5349 }
5350
5351 Ok(Vec::new())
5352 }
5353}
5354
5355#[cfg(test)]
5356mod elements_rpc_tests {
5357 use super::*;
5358 use httpmock::prelude::*;
5359 use serial_test::serial;
5360 use std::collections::HashMap;
5361
5362 #[test]
5363 fn test_elements_rpc_new() {
5364 let rpc = ElementsRpc::new(
5365 "http://localhost:18884".to_string(),
5366 "user".to_string(),
5367 "pass".to_string(),
5368 );
5369
5370 assert_eq!(rpc.base_url, "http://localhost:18884");
5371 assert_eq!(rpc.username, "user");
5372 assert_eq!(rpc.password, "pass");
5373 }
5374
5375 #[test]
5376 #[serial]
5377 fn test_elements_rpc_from_env_missing_vars() {
5378 // Store original values to restore later
5379 let original_url = env::var("ELEMENTS_RPC_URL").ok();
5380 let original_user = env::var("ELEMENTS_RPC_USER").ok();
5381 let original_password = env::var("ELEMENTS_RPC_PASSWORD").ok();
5382
5383 // Clear environment variables to test error handling
5384 env::remove_var("ELEMENTS_RPC_URL");
5385 env::remove_var("ELEMENTS_RPC_USER");
5386 env::remove_var("ELEMENTS_RPC_PASSWORD");
5387
5388 let result = ElementsRpc::from_env();
5389 assert!(
5390 result.is_err(),
5391 "ElementsRpc::from_env() should fail when env vars are missing"
5392 );
5393
5394 match result.unwrap_err() {
5395 AmpError::Validation(msg) => {
5396 assert!(
5397 msg.contains("ELEMENTS_RPC_URL"),
5398 "Error message should mention missing ELEMENTS_RPC_URL"
5399 );
5400 }
5401 _ => panic!("Expected validation error"),
5402 }
5403
5404 // Restore original values or keep removed if they weren't set
5405 if let Some(val) = original_url {
5406 env::set_var("ELEMENTS_RPC_URL", val);
5407 }
5408 if let Some(val) = original_user {
5409 env::set_var("ELEMENTS_RPC_USER", val);
5410 }
5411 if let Some(val) = original_password {
5412 env::set_var("ELEMENTS_RPC_PASSWORD", val);
5413 }
5414 }
5415
5416 #[test]
5417 #[serial]
5418 fn test_elements_rpc_from_env_success() {
5419 // Store original values to restore later
5420 let original_url = env::var("ELEMENTS_RPC_URL").ok();
5421 let original_user = env::var("ELEMENTS_RPC_USER").ok();
5422 let original_password = env::var("ELEMENTS_RPC_PASSWORD").ok();
5423
5424 // Set test values
5425 env::set_var("ELEMENTS_RPC_URL", "http://localhost:18884");
5426 env::set_var("ELEMENTS_RPC_USER", "testuser");
5427 env::set_var("ELEMENTS_RPC_PASSWORD", "testpass");
5428
5429 let result = ElementsRpc::from_env();
5430 assert!(
5431 result.is_ok(),
5432 "ElementsRpc::from_env() should succeed when all env vars are set"
5433 );
5434
5435 let rpc = result.unwrap();
5436 assert_eq!(rpc.base_url, "http://localhost:18884");
5437 assert_eq!(rpc.username, "testuser");
5438 assert_eq!(rpc.password, "testpass");
5439
5440 // Restore original values or remove if they weren't set
5441 match original_url {
5442 Some(val) => env::set_var("ELEMENTS_RPC_URL", val),
5443 None => env::remove_var("ELEMENTS_RPC_URL"),
5444 }
5445 match original_user {
5446 Some(val) => env::set_var("ELEMENTS_RPC_USER", val),
5447 None => env::remove_var("ELEMENTS_RPC_USER"),
5448 }
5449 match original_password {
5450 Some(val) => env::set_var("ELEMENTS_RPC_PASSWORD", val),
5451 None => env::remove_var("ELEMENTS_RPC_PASSWORD"),
5452 }
5453 }
5454
5455 #[test]
5456 fn test_elements_rpc_method_signatures() {
5457 // Test that all new methods have correct signatures and can be called
5458 let rpc = ElementsRpc::new(
5459 "http://localhost:18884".to_string(),
5460 "user".to_string(),
5461 "pass".to_string(),
5462 );
5463
5464 // Test that methods exist and have correct signatures (compilation test)
5465 let _: std::pin::Pin<
5466 Box<dyn std::future::Future<Output = Result<Vec<Unspent>, AmpError>> + Send + '_>,
5467 > = Box::pin(rpc.list_unspent(Some("test_asset")));
5468
5469 let inputs = vec![TxInput {
5470 txid: "test_txid".to_string(),
5471 vout: 0,
5472 sequence: None,
5473 }];
5474 let outputs = std::collections::HashMap::new();
5475 let assets = std::collections::HashMap::new();
5476
5477 let _: std::pin::Pin<
5478 Box<dyn std::future::Future<Output = Result<String, AmpError>> + Send + '_>,
5479 > = Box::pin(rpc.create_raw_transaction(inputs, outputs, assets));
5480
5481 let _: std::pin::Pin<
5482 Box<dyn std::future::Future<Output = Result<String, AmpError>> + Send + '_>,
5483 > = Box::pin(rpc.send_raw_transaction("test_hex"));
5484
5485 let _: std::pin::Pin<
5486 Box<dyn std::future::Future<Output = Result<TransactionDetail, AmpError>> + Send + '_>,
5487 > = Box::pin(rpc.get_transaction("test_txid"));
5488 }
5489
5490 // Mock RPC response tests for UTXO and transaction operations
5491
5492 #[tokio::test]
5493 async fn test_get_network_info_success() {
5494 let server = MockServer::start();
5495
5496 let mock_response = serde_json::json!({
5497 "jsonrpc": "1.0",
5498 "id": "amp-client",
5499 "result": {
5500 "version": 220000,
5501 "subversion": "/Liquid:22.0.0/",
5502 "protocolversion": 70016,
5503 "localservices": "0000000000000409",
5504 "localrelay": true,
5505 "timeoffset": 0,
5506 "networkactive": true,
5507 "connections": 8,
5508 "networks": [],
5509 "relayfee": 0.00001000,
5510 "incrementalfee": 0.00001000,
5511 "localaddresses": [],
5512 "warnings": ""
5513 }
5514 });
5515
5516 let mock = server.mock(|when, then| {
5517 when.method(POST)
5518 .path("/")
5519 .header("authorization", "Basic dXNlcjpwYXNz") // base64 of "user:pass"
5520 .json_body(serde_json::json!({
5521 "jsonrpc": "1.0",
5522 "id": "amp-client",
5523 "method": "getnetworkinfo",
5524 "params": []
5525 }));
5526 then.status(200)
5527 .header("content-type", "application/json")
5528 .json_body(mock_response);
5529 });
5530
5531 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5532 let result = rpc.get_network_info().await;
5533
5534 assert!(result.is_ok());
5535 let network_info = result.unwrap();
5536 assert_eq!(network_info.version, 220000);
5537 assert_eq!(network_info.subversion, "/Liquid:22.0.0/");
5538 assert_eq!(network_info.connections, 8);
5539
5540 mock.assert();
5541 }
5542
5543 #[tokio::test]
5544 async fn test_get_blockchain_info_success() {
5545 let server = MockServer::start();
5546
5547 let mock_response = serde_json::json!({
5548 "jsonrpc": "1.0",
5549 "id": "amp-client",
5550 "result": {
5551 "chain": "liquidregtest",
5552 "blocks": 12345,
5553 "headers": 12345,
5554 "bestblockhash": "abc123def456789",
5555 "difficulty": 4.656542373906925e-10,
5556 "mediantime": 1640995200,
5557 "verificationprogress": 1.0,
5558 "initialblockdownload": false,
5559 "chainwork": "0000000000000000000000000000000000000000000000000000000000003039",
5560 "size_on_disk": 1234567,
5561 "pruned": false,
5562 "softforks": {},
5563 "warnings": ""
5564 }
5565 });
5566
5567 let mock = server.mock(|when, then| {
5568 when.method(POST)
5569 .path("/")
5570 .header("authorization", "Basic dXNlcjpwYXNz")
5571 .json_body(serde_json::json!({
5572 "jsonrpc": "1.0",
5573 "id": "amp-client",
5574 "method": "getblockchaininfo",
5575 "params": []
5576 }));
5577 then.status(200)
5578 .header("content-type", "application/json")
5579 .json_body(mock_response);
5580 });
5581
5582 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5583 let result = rpc.get_blockchain_info().await;
5584
5585 assert!(result.is_ok());
5586 let blockchain_info = result.unwrap();
5587 assert_eq!(blockchain_info.chain, "liquidregtest");
5588 assert_eq!(blockchain_info.blocks, 12345);
5589 assert_eq!(blockchain_info.bestblockhash, "abc123def456789");
5590
5591 mock.assert();
5592 }
5593
5594 #[tokio::test]
5595 async fn test_list_unspent_with_asset_filter() {
5596 let server = MockServer::start();
5597
5598 let mock_response = serde_json::json!({
5599 "jsonrpc": "1.0",
5600 "id": "amp-client",
5601 "result": [
5602 {
5603 "txid": "abc123def456789",
5604 "vout": 0,
5605 "amount": 100.0,
5606 "asset": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d",
5607 "address": "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
5608 "spendable": true,
5609 "confirmations": 6,
5610 "scriptpubkey": "76a914abc123def456789abc123def456789abc123de88ac"
5611 },
5612 {
5613 "txid": "def456abc123789",
5614 "vout": 1,
5615 "amount": 50.0,
5616 "asset": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d",
5617 "address": "lq1qq3xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
5618 "spendable": true,
5619 "confirmations": 3
5620 }
5621 ]
5622 });
5623
5624 let asset_id = "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d";
5625
5626 let mock = server.mock(|when, then| {
5627 when.method(POST)
5628 .path("/")
5629 .header("authorization", "Basic dXNlcjpwYXNz")
5630 .json_body(serde_json::json!({
5631 "jsonrpc": "1.0",
5632 "id": "amp-client",
5633 "method": "listunspent",
5634 "params": [1, 9999999, [], true, {"asset": asset_id}]
5635 }));
5636 then.status(200)
5637 .header("content-type", "application/json")
5638 .json_body(mock_response);
5639 });
5640
5641 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5642 let result = rpc.list_unspent(Some(asset_id)).await;
5643
5644 assert!(result.is_ok());
5645 let utxos = result.unwrap();
5646 assert_eq!(utxos.len(), 2);
5647 assert_eq!(utxos[0].txid, "abc123def456789");
5648 assert_eq!(utxos[0].amount, 100.0);
5649 assert_eq!(utxos[0].asset, asset_id);
5650 assert_eq!(utxos[1].txid, "def456abc123789");
5651 assert_eq!(utxos[1].amount, 50.0);
5652
5653 mock.assert();
5654 }
5655
5656 #[tokio::test]
5657 async fn test_list_unspent_without_filter() {
5658 let server = MockServer::start();
5659
5660 let mock_response = serde_json::json!({
5661 "jsonrpc": "1.0",
5662 "id": "amp-client",
5663 "result": [
5664 {
5665 "txid": "ghi789jkl012345",
5666 "vout": 0,
5667 "amount": 25.0,
5668 "asset": "different_asset_id",
5669 "address": "lq1qq4xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
5670 "spendable": true,
5671 "confirmations": 10
5672 }
5673 ]
5674 });
5675
5676 let mock = server.mock(|when, then| {
5677 when.method(POST)
5678 .path("/")
5679 .header("authorization", "Basic dXNlcjpwYXNz")
5680 .json_body(serde_json::json!({
5681 "jsonrpc": "1.0",
5682 "id": "amp-client",
5683 "method": "listunspent",
5684 "params": [1, 9999999, [], true]
5685 }));
5686 then.status(200)
5687 .header("content-type", "application/json")
5688 .json_body(mock_response);
5689 });
5690
5691 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5692 let result = rpc.list_unspent(None).await;
5693
5694 assert!(result.is_ok());
5695 let utxos = result.unwrap();
5696 assert_eq!(utxos.len(), 1);
5697 assert_eq!(utxos[0].txid, "ghi789jkl012345");
5698 assert_eq!(utxos[0].amount, 25.0);
5699
5700 mock.assert();
5701 }
5702
5703 #[tokio::test]
5704 async fn test_create_raw_transaction_success() {
5705 let server = MockServer::start();
5706
5707 let mock_response = serde_json::json!({
5708 "jsonrpc": "1.0",
5709 "id": "amp-client",
5710 "result": "0200000000010abc123def456789abc123def456789abc123def456789abc123def456789abc123def456789000000006b483045022100..."
5711 });
5712
5713 let mock = server.mock(|when, then| {
5714 when.method(POST)
5715 .path("/")
5716 .header("authorization", "Basic dXNlcjpwYXNz")
5717 .json_body(serde_json::json!({
5718 "jsonrpc": "1.0",
5719 "id": "amp-client",
5720 "method": "createrawtransaction",
5721 "params": [
5722 [
5723 {
5724 "txid": "input_txid_123",
5725 "vout": 0,
5726 "sequence": 4294967295u32
5727 }
5728 ],
5729 {
5730 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq": 100.0
5731 },
5732 0,
5733 false,
5734 {
5735 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq": "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d"
5736 }
5737 ]
5738 }));
5739 then.status(200)
5740 .header("content-type", "application/json")
5741 .json_body(mock_response);
5742 });
5743
5744 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5745
5746 let inputs = vec![TxInput {
5747 txid: "input_txid_123".to_string(),
5748 vout: 0,
5749 sequence: Some(0xffffffff),
5750 }];
5751
5752 let mut outputs = HashMap::new();
5753 outputs.insert(
5754 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
5755 100.0,
5756 );
5757
5758 let mut assets = HashMap::new();
5759 assets.insert(
5760 "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
5761 "6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d".to_string(),
5762 );
5763
5764 let result = rpc.create_raw_transaction(inputs, outputs, assets).await;
5765
5766 assert!(result.is_ok());
5767 let raw_tx = result.unwrap();
5768 assert!(raw_tx.starts_with("0200000000010abc123def456789"));
5769
5770 mock.assert();
5771 }
5772
5773 #[tokio::test]
5774 async fn test_send_raw_transaction_success() {
5775 let server = MockServer::start();
5776
5777 let mock_response = serde_json::json!({
5778 "jsonrpc": "1.0",
5779 "id": "amp-client",
5780 "result": "abc123def456789abc123def456789abc123def456789abc123def456789abc123de"
5781 });
5782
5783 let signed_tx_hex = "0200000000010abc123def456789abc123def456789abc123def456789abc123def456789abc123def456789000000006b483045022100...";
5784
5785 let mock = server.mock(|when, then| {
5786 when.method(POST)
5787 .path("/")
5788 .header("authorization", "Basic dXNlcjpwYXNz")
5789 .json_body(serde_json::json!({
5790 "jsonrpc": "1.0",
5791 "id": "amp-client",
5792 "method": "sendrawtransaction",
5793 "params": [signed_tx_hex]
5794 }));
5795 then.status(200)
5796 .header("content-type", "application/json")
5797 .json_body(mock_response);
5798 });
5799
5800 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5801 let result = rpc.send_raw_transaction(signed_tx_hex).await;
5802
5803 assert!(result.is_ok());
5804 let txid = result.unwrap();
5805 assert_eq!(
5806 txid,
5807 "abc123def456789abc123def456789abc123def456789abc123def456789abc123de"
5808 );
5809
5810 mock.assert();
5811 }
5812
5813 #[tokio::test]
5814 async fn test_get_transaction_success() {
5815 let server = MockServer::start();
5816
5817 let mock_response = serde_json::json!({
5818 "jsonrpc": "1.0",
5819 "id": "amp-client",
5820 "result": {
5821 "txid": "abc123def456789abc123def456789abc123def456789abc123def456789abc123de",
5822 "confirmations": 6,
5823 "blockheight": 12345,
5824 "hex": "0200000000010abc123def456789...",
5825 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
5826 "blocktime": 1640995200,
5827 "time": 1640995200,
5828 "timereceived": 1640995180
5829 }
5830 });
5831
5832 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
5833
5834 let mock = server.mock(|when, then| {
5835 when.method(POST)
5836 .path("/")
5837 .header("authorization", "Basic dXNlcjpwYXNz")
5838 .json_body(serde_json::json!({
5839 "jsonrpc": "1.0",
5840 "id": "amp-client",
5841 "method": "gettransaction",
5842 "params": [txid, true]
5843 }));
5844 then.status(200)
5845 .header("content-type", "application/json")
5846 .json_body(mock_response);
5847 });
5848
5849 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5850 let result = rpc.get_transaction(txid).await;
5851
5852 assert!(result.is_ok());
5853 let tx_detail = result.unwrap();
5854 assert_eq!(tx_detail.txid, txid);
5855 assert_eq!(tx_detail.confirmations, 6);
5856 assert_eq!(tx_detail.blockheight, Some(12345));
5857 assert_eq!(tx_detail.blocktime, Some(1640995200));
5858
5859 mock.assert();
5860 }
5861
5862 // Error handling tests
5863
5864 #[tokio::test]
5865 async fn test_rpc_call_network_failure() {
5866 // Use an invalid URL to simulate network failure
5867 let rpc = ElementsRpc::new(
5868 "http://invalid-host:99999".to_string(),
5869 "user".to_string(),
5870 "pass".to_string(),
5871 );
5872
5873 let result = rpc.get_network_info().await;
5874 assert!(result.is_err());
5875
5876 match result.unwrap_err() {
5877 AmpError::Rpc(msg) => {
5878 assert!(msg.contains("Failed to send RPC request"));
5879 }
5880 _ => panic!("Expected RPC error for network failure"),
5881 }
5882 }
5883
5884 #[tokio::test]
5885 async fn test_rpc_call_http_error_status() {
5886 let server = MockServer::start();
5887
5888 let mock = server.mock(|when, then| {
5889 when.method(POST).path("/");
5890 then.status(500)
5891 .header("content-type", "application/json")
5892 .body("Internal Server Error");
5893 });
5894
5895 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5896 let result = rpc.get_network_info().await;
5897
5898 assert!(result.is_err());
5899 match result.unwrap_err() {
5900 AmpError::Rpc(msg) => {
5901 assert!(msg.contains("RPC request failed with status: 500"));
5902 }
5903 _ => panic!("Expected RPC error for HTTP error status"),
5904 }
5905
5906 mock.assert();
5907 }
5908
5909 #[tokio::test]
5910 async fn test_rpc_call_invalid_json_response() {
5911 let server = MockServer::start();
5912
5913 let mock = server.mock(|when, then| {
5914 when.method(POST).path("/");
5915 then.status(200)
5916 .header("content-type", "application/json")
5917 .body("invalid json response");
5918 });
5919
5920 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5921 let result = rpc.get_network_info().await;
5922
5923 assert!(result.is_err());
5924 match result.unwrap_err() {
5925 AmpError::Rpc(msg) => {
5926 assert!(msg.contains("Failed to parse RPC response"));
5927 }
5928 _ => panic!("Expected RPC error for invalid JSON"),
5929 }
5930
5931 mock.assert();
5932 }
5933
5934 #[tokio::test]
5935 async fn test_rpc_call_error_response() {
5936 let server = MockServer::start();
5937
5938 let mock_response = serde_json::json!({
5939 "jsonrpc": "1.0",
5940 "id": "amp-client",
5941 "result": null,
5942 "error": {
5943 "code": -32601,
5944 "message": "Method not found"
5945 }
5946 });
5947
5948 let mock = server.mock(|when, then| {
5949 when.method(POST).path("/");
5950 then.status(200)
5951 .header("content-type", "application/json")
5952 .json_body(mock_response);
5953 });
5954
5955 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5956 let result = rpc.get_network_info().await;
5957
5958 assert!(result.is_err());
5959 match result.unwrap_err() {
5960 AmpError::Rpc(msg) => {
5961 assert!(msg.contains("RPC error -32601: Method not found"));
5962 }
5963 _ => panic!("Expected RPC error for error response"),
5964 }
5965
5966 mock.assert();
5967 }
5968
5969 #[tokio::test]
5970 async fn test_rpc_call_missing_result() {
5971 let server = MockServer::start();
5972
5973 let mock_response = serde_json::json!({
5974 "jsonrpc": "1.0",
5975 "id": "amp-client",
5976 "result": null,
5977 "error": null
5978 });
5979
5980 let mock = server.mock(|when, then| {
5981 when.method(POST).path("/");
5982 then.status(200)
5983 .header("content-type", "application/json")
5984 .json_body(mock_response);
5985 });
5986
5987 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
5988 let result = rpc.get_network_info().await;
5989
5990 assert!(result.is_err());
5991 match result.unwrap_err() {
5992 AmpError::Rpc(msg) => {
5993 assert!(msg.contains("RPC response missing result field"));
5994 }
5995 _ => panic!("Expected RPC error for missing result"),
5996 }
5997
5998 mock.assert();
5999 }
6000
6001 // Authentication tests
6002
6003 #[tokio::test]
6004 async fn test_rpc_authentication_headers() {
6005 let server = MockServer::start();
6006
6007 let mock_response = serde_json::json!({
6008 "jsonrpc": "1.0",
6009 "id": "amp-client",
6010 "result": {
6011 "version": 220000,
6012 "subversion": "/Liquid:22.0.0/",
6013 "protocolversion": 70016,
6014 "localservices": "0000000000000409",
6015 "localrelay": true,
6016 "timeoffset": 0,
6017 "networkactive": true,
6018 "connections": 8,
6019 "networks": [],
6020 "relayfee": 0.00001000,
6021 "incrementalfee": 0.00001000,
6022 "localaddresses": [],
6023 "warnings": ""
6024 }
6025 });
6026
6027 // Test with custom username and password
6028 let mock = server.mock(|when, then| {
6029 when.method(POST)
6030 .path("/")
6031 .header("authorization", "Basic dGVzdHVzZXI6dGVzdHBhc3M=") // base64 of "testuser:testpass"
6032 .json_body(serde_json::json!({
6033 "jsonrpc": "1.0",
6034 "id": "amp-client",
6035 "method": "getnetworkinfo",
6036 "params": []
6037 }));
6038 then.status(200)
6039 .header("content-type", "application/json")
6040 .json_body(mock_response);
6041 });
6042
6043 let rpc = ElementsRpc::new(
6044 server.url("/"),
6045 "testuser".to_string(),
6046 "testpass".to_string(),
6047 );
6048 let result = rpc.get_network_info().await;
6049
6050 assert!(result.is_ok());
6051 mock.assert();
6052 }
6053
6054 // Wallet passphrase tests
6055
6056 #[tokio::test]
6057 async fn test_wallet_passphrase_success() {
6058 let server = MockServer::start();
6059
6060 let mock_response = serde_json::json!({
6061 "jsonrpc": "1.0",
6062 "id": "amp-client",
6063 "result": null
6064 });
6065
6066 let mock = server.mock(|when, then| {
6067 when.method(POST)
6068 .path("/")
6069 .header("authorization", "Basic dXNlcjpwYXNz")
6070 .json_body(serde_json::json!({
6071 "jsonrpc": "1.0",
6072 "id": "amp-client",
6073 "method": "walletpassphrase",
6074 "params": ["my_passphrase", 300]
6075 }));
6076 then.status(200)
6077 .header("content-type", "application/json")
6078 .json_body(mock_response);
6079 });
6080
6081 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6082 let result = rpc.wallet_passphrase("my_passphrase", 300).await;
6083
6084 assert!(result.is_ok());
6085 mock.assert();
6086 }
6087
6088 // Connection validation tests
6089
6090 #[tokio::test]
6091 async fn test_validate_connection_success() {
6092 let server = MockServer::start();
6093
6094 let mock_response = serde_json::json!({
6095 "jsonrpc": "1.0",
6096 "id": "amp-client",
6097 "result": {
6098 "version": 220000,
6099 "subversion": "/Liquid:22.0.0/",
6100 "protocolversion": 70016,
6101 "localservices": "0000000000000409",
6102 "localrelay": true,
6103 "timeoffset": 0,
6104 "networkactive": true,
6105 "connections": 8,
6106 "networks": [],
6107 "relayfee": 0.00001000,
6108 "incrementalfee": 0.00001000,
6109 "localaddresses": [],
6110 "warnings": ""
6111 }
6112 });
6113
6114 let mock = server.mock(|when, then| {
6115 when.method(POST).path("/");
6116 then.status(200)
6117 .header("content-type", "application/json")
6118 .json_body(mock_response);
6119 });
6120
6121 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6122 let result = rpc.validate_connection().await;
6123
6124 assert!(result.is_ok());
6125 mock.assert();
6126 }
6127
6128 #[tokio::test]
6129 async fn test_get_node_status_success() {
6130 let server = MockServer::start();
6131
6132 let network_mock_response = serde_json::json!({
6133 "jsonrpc": "1.0",
6134 "id": "amp-client",
6135 "result": {
6136 "version": 220000,
6137 "subversion": "/Liquid:22.0.0/",
6138 "protocolversion": 70016,
6139 "localservices": "0000000000000409",
6140 "localrelay": true,
6141 "timeoffset": 0,
6142 "networkactive": true,
6143 "connections": 8,
6144 "networks": [],
6145 "relayfee": 0.00001000,
6146 "incrementalfee": 0.00001000,
6147 "localaddresses": [],
6148 "warnings": ""
6149 }
6150 });
6151
6152 let blockchain_mock_response = serde_json::json!({
6153 "jsonrpc": "1.0",
6154 "id": "amp-client",
6155 "result": {
6156 "chain": "liquidregtest",
6157 "blocks": 12345,
6158 "headers": 12345,
6159 "bestblockhash": "abc123def456789",
6160 "difficulty": 4.656542373906925e-10,
6161 "mediantime": 1640995200,
6162 "verificationprogress": 1.0,
6163 "initialblockdownload": false,
6164 "chainwork": "0000000000000000000000000000000000000000000000000000000000003039",
6165 "size_on_disk": 1234567,
6166 "pruned": false,
6167 "softforks": {},
6168 "warnings": ""
6169 }
6170 });
6171
6172 let network_mock = server.mock(|when, then| {
6173 when.method(POST).path("/").json_body(serde_json::json!({
6174 "jsonrpc": "1.0",
6175 "id": "amp-client",
6176 "method": "getnetworkinfo",
6177 "params": []
6178 }));
6179 then.status(200)
6180 .header("content-type", "application/json")
6181 .json_body(network_mock_response);
6182 });
6183
6184 let blockchain_mock = server.mock(|when, then| {
6185 when.method(POST).path("/").json_body(serde_json::json!({
6186 "jsonrpc": "1.0",
6187 "id": "amp-client",
6188 "method": "getblockchaininfo",
6189 "params": []
6190 }));
6191 then.status(200)
6192 .header("content-type", "application/json")
6193 .json_body(blockchain_mock_response);
6194 });
6195
6196 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6197 let result = rpc.get_node_status().await;
6198
6199 assert!(result.is_ok());
6200 let (network_info, blockchain_info) = result.unwrap();
6201 assert_eq!(network_info.version, 220000);
6202 assert_eq!(blockchain_info.blocks, 12345);
6203
6204 network_mock.assert();
6205 blockchain_mock.assert();
6206 }
6207
6208 // Tests for UTXO selection and transaction building logic
6209
6210 #[tokio::test]
6211 async fn test_build_distribution_transaction_zero_amount() {
6212 let rpc = ElementsRpc::new(
6213 "http://localhost:18884".to_string(),
6214 "user".to_string(),
6215 "pass".to_string(),
6216 );
6217
6218 let address_amounts = HashMap::new(); // Empty distribution
6219
6220 let result = rpc
6221 .build_distribution_transaction(
6222 "test_wallet",
6223 "asset_id",
6224 address_amounts,
6225 "change_address",
6226 1.0,
6227 )
6228 .await;
6229
6230 assert!(result.is_err());
6231 match result.unwrap_err() {
6232 AmpError::Validation(msg) => {
6233 assert!(msg.contains("Total distribution amount must be greater than zero"));
6234 }
6235 _ => panic!("Expected validation error for zero distribution amount"),
6236 }
6237 }
6238
6239 #[tokio::test]
6240 async fn test_sign_transaction_validation() {
6241 let rpc = ElementsRpc::new(
6242 "http://localhost:18884".to_string(),
6243 "user".to_string(),
6244 "pass".to_string(),
6245 );
6246
6247 // Mock signer for testing
6248 struct MockSigner {
6249 should_fail: bool,
6250 return_value: String,
6251 }
6252
6253 #[async_trait::async_trait]
6254 impl crate::signer::Signer for MockSigner {
6255 async fn sign_transaction(
6256 &self,
6257 _unsigned_tx: &str,
6258 ) -> Result<String, crate::signer::SignerError> {
6259 if self.should_fail {
6260 Err(crate::signer::SignerError::Lwk(
6261 "Mock signing failure".to_string(),
6262 ))
6263 } else {
6264 // Return a longer hex string to simulate signed transaction (20+ bytes when decoded)
6265 Ok(format!(
6266 "{}deadbeefcafebabe1234567890abcdef",
6267 self.return_value
6268 ))
6269 }
6270 }
6271
6272 fn as_any(&self) -> &dyn std::any::Any {
6273 self
6274 }
6275 }
6276
6277 // Test empty transaction hex
6278 let mock_signer = MockSigner {
6279 should_fail: false,
6280 return_value: "".to_string(),
6281 };
6282 let result = rpc.sign_transaction("", &mock_signer).await;
6283 assert!(result.is_err());
6284 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
6285
6286 // Test odd length hex
6287 let result = rpc.sign_transaction("abc", &mock_signer).await;
6288 assert!(result.is_err());
6289 assert!(result.unwrap_err().to_string().contains("even length"));
6290
6291 // Test invalid hex characters
6292 let result = rpc.sign_transaction("abcg", &mock_signer).await;
6293 assert!(result.is_err());
6294 assert!(result
6295 .unwrap_err()
6296 .to_string()
6297 .contains("invalid hex characters"));
6298
6299 // Test signer failure
6300 let mock_signer = MockSigner {
6301 should_fail: true,
6302 return_value: "".to_string(),
6303 };
6304 let result = rpc.sign_transaction("abcd", &mock_signer).await;
6305 assert!(result.is_err());
6306 assert!(result
6307 .unwrap_err()
6308 .to_string()
6309 .contains("Mock signing failure"));
6310
6311 // Test successful signing
6312 let mock_signer = MockSigner {
6313 should_fail: false,
6314 return_value: "abcd".to_string(),
6315 };
6316 let result = rpc.sign_transaction("abcd", &mock_signer).await;
6317 if result.is_err() {
6318 println!("Error: {}", result.as_ref().unwrap_err());
6319 }
6320 assert!(result.is_ok());
6321 assert_eq!(result.unwrap(), "abcddeadbeefcafebabe1234567890abcdef");
6322 }
6323
6324 #[tokio::test]
6325 async fn test_sign_transaction_validation_edge_cases() {
6326 let rpc = ElementsRpc::new(
6327 "http://localhost:18884".to_string(),
6328 "user".to_string(),
6329 "pass".to_string(),
6330 );
6331
6332 // Mock signer that returns invalid responses
6333 struct BadMockSigner {
6334 return_empty: bool,
6335 return_odd_length: bool,
6336 return_invalid_hex: bool,
6337 return_shorter: bool,
6338 }
6339
6340 #[async_trait::async_trait]
6341 impl crate::signer::Signer for BadMockSigner {
6342 async fn sign_transaction(
6343 &self,
6344 unsigned_tx: &str,
6345 ) -> Result<String, crate::signer::SignerError> {
6346 if self.return_empty {
6347 Ok("".to_string())
6348 } else if self.return_odd_length {
6349 Ok("abc".to_string())
6350 } else if self.return_invalid_hex {
6351 Ok("abcg".to_string())
6352 } else if self.return_shorter {
6353 Ok("ab".to_string()) // Shorter than input "abcd"
6354 } else {
6355 Ok(format!("{}deadbeef", unsigned_tx))
6356 }
6357 }
6358
6359 fn as_any(&self) -> &dyn std::any::Any {
6360 self
6361 }
6362 }
6363
6364 // Test signer returning empty string
6365 let bad_signer = BadMockSigner {
6366 return_empty: true,
6367 return_odd_length: false,
6368 return_invalid_hex: false,
6369 return_shorter: false,
6370 };
6371 let result = rpc.sign_transaction("abcd", &bad_signer).await;
6372 assert!(result.is_err());
6373 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
6374
6375 // Test signer returning odd length hex
6376 let bad_signer = BadMockSigner {
6377 return_empty: false,
6378 return_odd_length: true,
6379 return_invalid_hex: false,
6380 return_shorter: false,
6381 };
6382 let result = rpc.sign_transaction("abcd", &bad_signer).await;
6383 assert!(result.is_err());
6384 assert!(result.unwrap_err().to_string().contains("even length"));
6385
6386 // Test signer returning invalid hex
6387 let bad_signer = BadMockSigner {
6388 return_empty: false,
6389 return_odd_length: false,
6390 return_invalid_hex: true,
6391 return_shorter: false,
6392 };
6393 let result = rpc.sign_transaction("abcd", &bad_signer).await;
6394 assert!(result.is_err());
6395 assert!(result
6396 .unwrap_err()
6397 .to_string()
6398 .contains("invalid hex characters"));
6399
6400 // Test signer returning shorter transaction (invalid)
6401 let bad_signer = BadMockSigner {
6402 return_empty: false,
6403 return_odd_length: false,
6404 return_invalid_hex: false,
6405 return_shorter: true,
6406 };
6407 let result = rpc.sign_transaction("abcd", &bad_signer).await;
6408 assert!(result.is_err());
6409 assert!(result
6410 .unwrap_err()
6411 .to_string()
6412 .contains("shorter than unsigned transaction"));
6413 }
6414
6415 #[tokio::test]
6416 async fn test_sign_transaction_minimum_size_validation() {
6417 let rpc = ElementsRpc::new(
6418 "http://localhost:18884".to_string(),
6419 "user".to_string(),
6420 "pass".to_string(),
6421 );
6422
6423 // Mock signer that returns very small transactions
6424 struct TinyMockSigner;
6425
6426 #[async_trait::async_trait]
6427 impl crate::signer::Signer for TinyMockSigner {
6428 async fn sign_transaction(
6429 &self,
6430 _unsigned_tx: &str,
6431 ) -> Result<String, crate::signer::SignerError> {
6432 Ok("abcd".to_string()) // Only 2 bytes when decoded
6433 }
6434
6435 fn as_any(&self) -> &dyn std::any::Any {
6436 self
6437 }
6438 }
6439
6440 let tiny_signer = TinyMockSigner;
6441 let result = rpc.sign_transaction("abcd", &tiny_signer).await;
6442 assert!(result.is_err());
6443 let error_msg = result.unwrap_err().to_string();
6444 assert!(error_msg.contains("minimum size"));
6445 assert!(error_msg.contains("minimum is 10 bytes"));
6446 }
6447
6448 #[tokio::test]
6449 async fn test_sign_transaction_success_case() {
6450 let rpc = ElementsRpc::new(
6451 "http://localhost:18884".to_string(),
6452 "user".to_string(),
6453 "pass".to_string(),
6454 );
6455
6456 // Mock signer that returns a valid signed transaction
6457 struct GoodMockSigner;
6458
6459 #[async_trait::async_trait]
6460 impl crate::signer::Signer for GoodMockSigner {
6461 async fn sign_transaction(
6462 &self,
6463 unsigned_tx: &str,
6464 ) -> Result<String, crate::signer::SignerError> {
6465 // Return a longer valid hex string (20+ bytes when decoded)
6466 Ok(format!("{}deadbeefcafebabe1234567890abcdef", unsigned_tx))
6467 }
6468
6469 fn as_any(&self) -> &dyn std::any::Any {
6470 self
6471 }
6472 }
6473
6474 let good_signer = GoodMockSigner;
6475
6476 // Test with a reasonable sized unsigned transaction
6477 let unsigned_tx = "0200000000010123456789abcdef"; // 14 bytes when decoded
6478 let result = rpc.sign_transaction(unsigned_tx, &good_signer).await;
6479
6480 assert!(result.is_ok());
6481 let signed_tx = result.unwrap();
6482 assert!(signed_tx.starts_with(unsigned_tx));
6483 assert!(signed_tx.len() > unsigned_tx.len());
6484 assert!(signed_tx.contains("deadbeefcafebabe"));
6485 }
6486
6487 #[tokio::test]
6488 async fn test_sign_and_broadcast_transaction_mock() {
6489 // Create a mock server for testing the broadcast part
6490 let server = MockServer::start();
6491
6492 // Mock the RPC response for sendrawtransaction
6493 let mock = server.mock(|when, then| {
6494 when.method(POST).path("/").json_body(serde_json::json!({
6495 "jsonrpc": "1.0",
6496 "id": "amp-client",
6497 "method": "sendrawtransaction",
6498 "params": ["0200000000010123456789abcdefdeadbeefcafebabe1234567890abcdef"]
6499 }));
6500 then.status(200).json_body(serde_json::json!({
6501 "jsonrpc": "1.0",
6502 "id": "amp-client",
6503 "result": "abc123def456789",
6504 "error": null
6505 }));
6506 });
6507
6508 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6509
6510 // Mock signer for testing
6511 struct TestMockSigner;
6512
6513 #[async_trait::async_trait]
6514 impl crate::signer::Signer for TestMockSigner {
6515 async fn sign_transaction(
6516 &self,
6517 unsigned_tx: &str,
6518 ) -> Result<String, crate::signer::SignerError> {
6519 Ok(format!("{}deadbeefcafebabe1234567890abcdef", unsigned_tx))
6520 }
6521
6522 fn as_any(&self) -> &dyn std::any::Any {
6523 self
6524 }
6525 }
6526
6527 let signer = TestMockSigner;
6528 let unsigned_tx = "0200000000010123456789abcdef";
6529
6530 let result = rpc
6531 .sign_and_broadcast_transaction(unsigned_tx, &signer)
6532 .await;
6533
6534 assert!(result.is_ok());
6535 assert_eq!(result.unwrap(), "abc123def456789");
6536
6537 // Verify the mock was called
6538 mock.assert();
6539 }
6540
6541 #[tokio::test]
6542 async fn test_sign_and_broadcast_transaction_signing_failure() {
6543 let rpc = ElementsRpc::new(
6544 "http://localhost:18884".to_string(),
6545 "user".to_string(),
6546 "pass".to_string(),
6547 );
6548
6549 // Mock signer that fails
6550 struct FailingSigner;
6551
6552 #[async_trait::async_trait]
6553 impl crate::signer::Signer for FailingSigner {
6554 async fn sign_transaction(
6555 &self,
6556 _unsigned_tx: &str,
6557 ) -> Result<String, crate::signer::SignerError> {
6558 Err(crate::signer::SignerError::Lwk(
6559 "Signing failed".to_string(),
6560 ))
6561 }
6562
6563 fn as_any(&self) -> &dyn std::any::Any {
6564 self
6565 }
6566 }
6567
6568 let failing_signer = FailingSigner;
6569 let result = rpc
6570 .sign_and_broadcast_transaction("abcd", &failing_signer)
6571 .await;
6572
6573 assert!(result.is_err());
6574 let error_msg = result.unwrap_err().to_string();
6575 // The error should be a Signer error containing the original failure message
6576 assert!(error_msg.contains("Signer error"));
6577 assert!(error_msg.contains("Signing failed"));
6578 }
6579
6580 #[tokio::test]
6581 async fn test_sign_and_broadcast_transaction_broadcast_failure() {
6582 // Create a mock server that returns an error for broadcast
6583 let server = MockServer::start();
6584
6585 let mock = server.mock(|when, then| {
6586 when.method(POST).path("/");
6587 then.status(200).json_body(serde_json::json!({
6588 "jsonrpc": "1.0",
6589 "id": "amp-client",
6590 "result": null,
6591 "error": {
6592 "code": -26,
6593 "message": "Transaction rejected"
6594 }
6595 }));
6596 });
6597
6598 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6599
6600 // Mock signer that succeeds
6601 struct WorkingSigner;
6602
6603 #[async_trait::async_trait]
6604 impl crate::signer::Signer for WorkingSigner {
6605 async fn sign_transaction(
6606 &self,
6607 unsigned_tx: &str,
6608 ) -> Result<String, crate::signer::SignerError> {
6609 Ok(format!("{}deadbeefcafebabe1234567890abcdef", unsigned_tx))
6610 }
6611
6612 fn as_any(&self) -> &dyn std::any::Any {
6613 self
6614 }
6615 }
6616
6617 let working_signer = WorkingSigner;
6618 let unsigned_tx = "0200000000010123456789abcdef";
6619
6620 let result = rpc
6621 .sign_and_broadcast_transaction(unsigned_tx, &working_signer)
6622 .await;
6623
6624 assert!(result.is_err());
6625 let error_msg = result.unwrap_err().to_string();
6626 assert!(error_msg.contains("Failed during transaction broadcast phase"));
6627 assert!(error_msg.contains("Transaction rejected"));
6628
6629 mock.assert();
6630 }
6631
6632 #[tokio::test]
6633 async fn test_wait_for_confirmations_success() {
6634 let server = MockServer::start();
6635
6636 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
6637
6638 // First call returns 1 confirmation (not enough)
6639 let _mock_response_1 = serde_json::json!({
6640 "jsonrpc": "1.0",
6641 "id": "amp-client",
6642 "result": {
6643 "txid": txid,
6644 "confirmations": 1,
6645 "blockheight": 12345,
6646 "hex": "0200000000010abc123def456789...",
6647 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
6648 "blocktime": 1640995200,
6649 "time": 1640995200,
6650 "timereceived": 1640995180
6651 }
6652 });
6653
6654 // Second call returns 2 confirmations (sufficient)
6655 let mock_response_2 = serde_json::json!({
6656 "jsonrpc": "1.0",
6657 "id": "amp-client",
6658 "result": {
6659 "txid": txid,
6660 "confirmations": 2,
6661 "blockheight": 12345,
6662 "hex": "0200000000010abc123def456789...",
6663 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
6664 "blocktime": 1640995200,
6665 "time": 1640995200,
6666 "timereceived": 1640995180
6667 }
6668 });
6669
6670 // Create a mock that returns 2 confirmations immediately (simpler test)
6671 let mock = server.mock(|when, then| {
6672 when.method(POST)
6673 .path("/")
6674 .header("authorization", "Basic dXNlcjpwYXNz")
6675 .json_body(serde_json::json!({
6676 "jsonrpc": "1.0",
6677 "id": "amp-client",
6678 "method": "gettransaction",
6679 "params": [txid, true]
6680 }));
6681 then.status(200)
6682 .header("content-type", "application/json")
6683 .json_body(mock_response_2); // Return sufficient confirmations immediately
6684 });
6685
6686 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6687
6688 // Use fast polling (1 second) for testing
6689 let result = rpc
6690 .wait_for_confirmations_with_interval(txid, Some(2), Some(1), Some(1))
6691 .await;
6692
6693 assert!(result.is_ok());
6694 let tx_detail = result.unwrap();
6695 assert_eq!(tx_detail.confirmations, 2);
6696 assert_eq!(tx_detail.txid, txid);
6697
6698 // Mock should have been called once
6699 mock.assert();
6700 }
6701
6702 #[tokio::test]
6703 async fn test_wait_for_confirmations_timeout() {
6704 let server = MockServer::start();
6705
6706 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
6707
6708 // Always return insufficient confirmations
6709 let mock_response = serde_json::json!({
6710 "jsonrpc": "1.0",
6711 "id": "amp-client",
6712 "result": {
6713 "txid": txid,
6714 "confirmations": 1,
6715 "blockheight": 12345,
6716 "hex": "0200000000010abc123def456789...",
6717 "blockhash": null,
6718 "blocktime": null,
6719 "time": null,
6720 "timereceived": null
6721 }
6722 });
6723
6724 let _mock = server.mock(|when, then| {
6725 when.method(POST)
6726 .path("/")
6727 .header("authorization", "Basic dXNlcjpwYXNz")
6728 .json_body(serde_json::json!({
6729 "jsonrpc": "1.0",
6730 "id": "amp-client",
6731 "method": "gettransaction",
6732 "params": [txid, true]
6733 }));
6734 then.status(200)
6735 .header("content-type", "application/json")
6736 .json_body(mock_response);
6737 });
6738
6739 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6740
6741 // Use a very short timeout for testing (0 = 3 seconds) and fast polling (1 second)
6742 let result = rpc
6743 .wait_for_confirmations_with_interval(txid, Some(2), Some(0), Some(1))
6744 .await;
6745
6746 assert!(result.is_err());
6747 match result.unwrap_err() {
6748 AmpError::Timeout(msg) => {
6749 assert!(msg.contains("Timeout waiting for confirmations"));
6750 assert!(msg.contains(txid));
6751 assert!(msg.contains("retry confirmation"));
6752 }
6753 _ => panic!("Expected timeout error"),
6754 }
6755
6756 // Mock will be called multiple times during the timeout period
6757 // We don't assert on the exact number since it depends on timing
6758 }
6759
6760 #[tokio::test]
6761 async fn test_wait_for_confirmations_immediate_success() {
6762 let server = MockServer::start();
6763
6764 let txid = "abc123def456789abc123def456789abc123def456789abc123def456789abc123de";
6765
6766 // Transaction already has sufficient confirmations
6767 let mock_response = serde_json::json!({
6768 "jsonrpc": "1.0",
6769 "id": "amp-client",
6770 "result": {
6771 "txid": txid,
6772 "confirmations": 5,
6773 "blockheight": 12345,
6774 "hex": "0200000000010abc123def456789...",
6775 "blockhash": "def456abc123789def456abc123789def456abc123789def456abc123789def456ab",
6776 "blocktime": 1640995200,
6777 "time": 1640995200,
6778 "timereceived": 1640995180
6779 }
6780 });
6781
6782 let mock = server.mock(|when, then| {
6783 when.method(POST)
6784 .path("/")
6785 .header("authorization", "Basic dXNlcjpwYXNz")
6786 .json_body(serde_json::json!({
6787 "jsonrpc": "1.0",
6788 "id": "amp-client",
6789 "method": "gettransaction",
6790 "params": [txid, true]
6791 }));
6792 then.status(200)
6793 .header("content-type", "application/json")
6794 .json_body(mock_response);
6795 });
6796
6797 let rpc = ElementsRpc::new(server.url("/"), "user".to_string(), "pass".to_string());
6798
6799 let result = rpc.wait_for_confirmations(txid, Some(2), Some(10)).await;
6800
6801 assert!(result.is_ok());
6802 let tx_detail = result.unwrap();
6803 assert_eq!(tx_detail.confirmations, 5);
6804 assert_eq!(tx_detail.txid, txid);
6805
6806 // Should only need one call since confirmations are already sufficient
6807 mock.assert();
6808 }
6809}
6810
6811/// Configuration for retry behavior in API requests
6812#[derive(Debug, Clone)]
6813pub struct RetryConfig {
6814 /// Maximum number of retry attempts
6815 pub max_attempts: u32,
6816 /// Base delay in milliseconds for exponential backoff
6817 pub base_delay_ms: u64,
6818 /// Maximum delay in milliseconds to cap exponential backoff
6819 pub max_delay_ms: u64,
6820 /// Request timeout in seconds
6821 pub timeout_seconds: u64,
6822}
6823
6824impl Default for RetryConfig {
6825 fn default() -> Self {
6826 Self {
6827 max_attempts: 3,
6828 base_delay_ms: 1000,
6829 max_delay_ms: 30_000,
6830 timeout_seconds: 10,
6831 }
6832 }
6833}
6834
6835impl RetryConfig {
6836 /// Creates a `RetryConfig` from environment variables with default fallbacks
6837 ///
6838 /// Environment variables:
6839 /// - `API_RETRY_MAX_ATTEMPTS`: Maximum retry attempts (default: 3)
6840 /// - `API_RETRY_BASE_DELAY_MS`: Base delay in milliseconds (default: 1000)
6841 /// - `API_RETRY_MAX_DELAY_MS`: Maximum delay in milliseconds (default: 30000)
6842 /// - `API_REQUEST_TIMEOUT_SECONDS`: Request timeout in seconds (default: 10)
6843 ///
6844 /// # Errors
6845 ///
6846 /// Returns an error if any environment variable contains an invalid value
6847 pub fn from_env() -> Result<Self, Error> {
6848 let max_attempts = match env::var("API_RETRY_MAX_ATTEMPTS") {
6849 Ok(val) => val.parse::<u32>().map_err(|e| {
6850 Error::InvalidRetryConfig(format!("Invalid API_RETRY_MAX_ATTEMPTS: {e}"))
6851 })?,
6852 Err(_) => 3,
6853 };
6854
6855 let base_delay_ms = match env::var("API_RETRY_BASE_DELAY_MS") {
6856 Ok(val) => val.parse::<u64>().map_err(|e| {
6857 Error::InvalidRetryConfig(format!("Invalid API_RETRY_BASE_DELAY_MS: {e}"))
6858 })?,
6859 Err(_) => 1000,
6860 };
6861
6862 let max_delay_ms = match env::var("API_RETRY_MAX_DELAY_MS") {
6863 Ok(val) => val.parse::<u64>().map_err(|e| {
6864 Error::InvalidRetryConfig(format!("Invalid API_RETRY_MAX_DELAY_MS: {e}"))
6865 })?,
6866 Err(_) => 30_000,
6867 };
6868
6869 let timeout_seconds = match env::var("API_REQUEST_TIMEOUT_SECONDS") {
6870 Ok(val) => val.parse::<u64>().map_err(|e| {
6871 Error::InvalidRetryConfig(format!("Invalid API_REQUEST_TIMEOUT_SECONDS: {e}"))
6872 })?,
6873 Err(_) => 10,
6874 };
6875
6876 // Validate configuration
6877 if max_attempts == 0 {
6878 return Err(Error::InvalidRetryConfig(
6879 "max_attempts must be greater than 0".to_string(),
6880 ));
6881 }
6882 if base_delay_ms == 0 {
6883 return Err(Error::InvalidRetryConfig(
6884 "base_delay_ms must be greater than 0".to_string(),
6885 ));
6886 }
6887 if max_delay_ms < base_delay_ms {
6888 return Err(Error::InvalidRetryConfig(
6889 "max_delay_ms must be greater than or equal to base_delay_ms".to_string(),
6890 ));
6891 }
6892 if timeout_seconds == 0 {
6893 return Err(Error::InvalidRetryConfig(
6894 "timeout_seconds must be greater than 0".to_string(),
6895 ));
6896 }
6897
6898 Ok(Self {
6899 max_attempts,
6900 base_delay_ms,
6901 max_delay_ms,
6902 timeout_seconds,
6903 })
6904 }
6905
6906 /// Creates a `RetryConfig` optimized for test environments
6907 ///
6908 /// Uses reduced values for faster test execution:
6909 /// - 2 retry attempts
6910 /// - 500ms base delay
6911 /// - 5000ms max delay
6912 /// - 5 second timeout
6913 #[must_use]
6914 pub const fn for_tests() -> Self {
6915 Self {
6916 max_attempts: 2,
6917 base_delay_ms: 500,
6918 max_delay_ms: 5000,
6919 timeout_seconds: 5,
6920 }
6921 }
6922
6923 /// Sets a custom timeout value
6924 #[must_use]
6925 pub const fn with_timeout(mut self, timeout_seconds: u64) -> Self {
6926 self.timeout_seconds = timeout_seconds;
6927 self
6928 }
6929
6930 /// Sets custom max attempts
6931 #[must_use]
6932 pub const fn with_max_attempts(mut self, max_attempts: u32) -> Self {
6933 self.max_attempts = max_attempts;
6934 self
6935 }
6936
6937 /// Sets custom base delay
6938 #[must_use]
6939 pub const fn with_base_delay_ms(mut self, base_delay_ms: u64) -> Self {
6940 self.base_delay_ms = base_delay_ms;
6941 self
6942 }
6943
6944 /// Sets custom max delay
6945 #[must_use]
6946 pub const fn with_max_delay_ms(mut self, max_delay_ms: u64) -> Self {
6947 self.max_delay_ms = max_delay_ms;
6948 self
6949 }
6950}
6951
6952/// HTTP client with sophisticated retry logic and exponential backoff
6953#[derive(Debug, Clone)]
6954pub struct RetryClient {
6955 client: Client,
6956 config: RetryConfig,
6957}
6958
6959impl RetryClient {
6960 /// Creates a new `RetryClient` with the given configuration
6961 #[must_use]
6962 pub fn new(config: RetryConfig) -> Self {
6963 Self {
6964 client: Client::new(),
6965 config,
6966 }
6967 }
6968
6969 /// Creates a new `RetryClient` with default configuration
6970 #[must_use]
6971 pub fn with_default_config() -> Self {
6972 Self::new(RetryConfig::default())
6973 }
6974
6975 /// Creates a new `RetryClient` with test-optimized configuration
6976 #[must_use]
6977 pub fn for_tests() -> Self {
6978 Self::new(RetryConfig::for_tests())
6979 }
6980
6981 /// Executes an HTTP request with retry logic and exponential backoff
6982 ///
6983 /// # Arguments
6984 /// * `request_builder` - A function that creates the request builder
6985 ///
6986 /// # Returns
6987 /// The response if successful, or an error after all retries are exhausted
6988 ///
6989 /// # Errors
6990 /// Returns `TokenError::Timeout` if the request times out
6991 /// Returns `TokenError::RateLimited` if rate limited and retries are exhausted
6992 /// Returns `TokenError::ObtainFailed` if all retry attempts fail
6993 #[allow(clippy::cognitive_complexity)]
6994 pub async fn execute_with_retry<F>(
6995 &self,
6996 request_builder: F,
6997 ) -> Result<reqwest::Response, TokenError>
6998 where
6999 F: Fn() -> reqwest::RequestBuilder + Send + Sync,
7000 {
7001 let mut last_error = String::new();
7002 let mut attempt = 0;
7003
7004 while attempt < self.config.max_attempts {
7005 attempt += 1;
7006
7007 // Create the request with timeout
7008 let request =
7009 request_builder().timeout(StdDuration::from_secs(self.config.timeout_seconds));
7010
7011 // Execute the request
7012 match request.send().await {
7013 Ok(response) => {
7014 let status = response.status();
7015
7016 // Handle rate limiting (429 Too Many Requests)
7017 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
7018 let retry_after = Self::extract_retry_after(&response).unwrap_or(60);
7019
7020 tracing::warn!(
7021 "Rate limited (429) on attempt {}/{}. Retry after {} seconds",
7022 attempt,
7023 self.config.max_attempts,
7024 retry_after
7025 );
7026
7027 // If this is our last attempt, return the rate limit error
7028 if attempt >= self.config.max_attempts {
7029 return Err(TokenError::rate_limited(retry_after));
7030 }
7031
7032 // Wait for the rate limit period (or our max delay, whichever is smaller)
7033 let delay_ms = std::cmp::min(retry_after * 1000, self.config.max_delay_ms);
7034 sleep(StdDuration::from_millis(delay_ms)).await;
7035 continue;
7036 }
7037
7038 // Handle other client errors (4xx) - these are generally not retryable
7039 if status.is_client_error() && status != reqwest::StatusCode::TOO_MANY_REQUESTS
7040 {
7041 last_error = format!("Client error: {status}");
7042 tracing::error!("Non-retryable client error: {}", status);
7043 break;
7044 }
7045
7046 // Handle server errors (5xx) - these are retryable
7047 if status.is_server_error() {
7048 last_error = format!("Server error: {status}");
7049 tracing::warn!(
7050 "Server error {} on attempt {}/{}",
7051 status,
7052 attempt,
7053 self.config.max_attempts
7054 );
7055
7056 if attempt < self.config.max_attempts {
7057 let delay = self.calculate_backoff_delay(attempt);
7058 sleep(delay).await;
7059 continue;
7060 }
7061 break;
7062 }
7063
7064 // Success case
7065 return Ok(response);
7066 }
7067 Err(e) => {
7068 last_error = e.to_string();
7069
7070 // Check if this is a timeout error
7071 if e.is_timeout() {
7072 tracing::warn!(
7073 "Request timeout on attempt {}/{}",
7074 attempt,
7075 self.config.max_attempts
7076 );
7077
7078 if attempt >= self.config.max_attempts {
7079 return Err(TokenError::timeout(self.config.timeout_seconds));
7080 }
7081 } else {
7082 tracing::warn!(
7083 "Request failed on attempt {}/{}: {}",
7084 attempt,
7085 self.config.max_attempts,
7086 e
7087 );
7088 }
7089
7090 // If we have more attempts, wait and retry
7091 if attempt < self.config.max_attempts {
7092 let delay = self.calculate_backoff_delay(attempt);
7093 sleep(delay).await;
7094 }
7095 }
7096 }
7097 }
7098
7099 // All retries exhausted
7100 Err(TokenError::obtain_failed(attempt, last_error))
7101 }
7102
7103 /// Calculates the delay for exponential backoff with jitter
7104 ///
7105 /// Uses the formula: `min(base_delay * 2^(attempt-1) + jitter, max_delay)`
7106 /// where jitter is a random value between 0 and `base_delay/2`
7107 pub fn calculate_backoff_delay(&self, attempt: u32) -> StdDuration {
7108 use rand::Rng;
7109
7110 let base_delay = self.config.base_delay_ms;
7111 let max_delay = self.config.max_delay_ms;
7112
7113 // Calculate exponential backoff: base_delay * 2^(attempt-1)
7114 let exponential_delay = base_delay * 2_u64.pow(attempt.saturating_sub(1));
7115
7116 // Add jitter (random value between 0 and base_delay/2)
7117 let jitter = rand::thread_rng().gen_range(0..=base_delay / 2);
7118 let total_delay = exponential_delay + jitter;
7119
7120 // Cap at max_delay
7121 let final_delay = std::cmp::min(total_delay, max_delay);
7122
7123 tracing::debug!(
7124 "Calculated backoff delay for attempt {}: {}ms (exponential: {}ms, jitter: {}ms, capped at: {}ms)",
7125 attempt,
7126 final_delay,
7127 exponential_delay,
7128 jitter,
7129 max_delay
7130 );
7131
7132 StdDuration::from_millis(final_delay)
7133 }
7134
7135 /// Extracts the Retry-After header value from a 429 response
7136 ///
7137 /// Returns the number of seconds to wait, or None if the header is not present
7138 /// or cannot be parsed
7139 fn extract_retry_after(response: &reqwest::Response) -> Option<u64> {
7140 response
7141 .headers()
7142 .get("retry-after")
7143 .and_then(|value| value.to_str().ok())
7144 .and_then(|s| s.parse::<u64>().ok())
7145 }
7146
7147 /// Gets the underlying reqwest client
7148 #[must_use]
7149 pub const fn client(&self) -> &Client {
7150 &self.client
7151 }
7152
7153 /// Gets the retry configuration
7154 #[must_use]
7155 pub const fn config(&self) -> &RetryConfig {
7156 &self.config
7157 }
7158}
7159
7160/// Singleton instance of the `TokenManager` for shared token storage across all `ApiClient` instances
7161static GLOBAL_TOKEN_MANAGER: OnceCell<Arc<TokenManager>> = OnceCell::const_new();
7162
7163/// Core token manager with proactive refresh and secure storage
7164#[derive(Debug)]
7165pub struct TokenManager {
7166 pub token_data: Arc<Mutex<Option<TokenData>>>,
7167 pub retry_client: RetryClient,
7168 base_url: Url,
7169 /// Semaphore to ensure only one token operation (obtain/refresh) happens at a time
7170 /// This prevents race conditions where multiple threads try to refresh/obtain simultaneously
7171 token_operation_semaphore: Arc<Semaphore>,
7172}
7173
7174impl TokenManager {
7175 /// Gets the global singleton instance of `TokenManager`
7176 ///
7177 /// This ensures all `ApiClient` instances share the same token storage,
7178 /// preventing multiple token acquisition attempts in concurrent tests.
7179 ///
7180 /// # Errors
7181 /// Returns an error if the `TokenManager` cannot be initialized
7182 pub async fn get_global_instance() -> Result<Arc<Self>, Error> {
7183 let manager = GLOBAL_TOKEN_MANAGER
7184 .get_or_try_init(|| async {
7185 let config = RetryConfig::from_env()?;
7186 let base_url = get_amp_api_base_url()?;
7187 let manager = Self::with_config_and_base_url(config, base_url).await?;
7188 Ok::<Arc<Self>, Error>(Arc::new(manager))
7189 })
7190 .await?;
7191
7192 Ok(manager.clone())
7193 }
7194
7195 /// Creates a new `TokenManager` with default configuration
7196 ///
7197 /// # Errors
7198 /// Returns an error if the base URL cannot be obtained from environment variables
7199 pub async fn new() -> Result<Self, Error> {
7200 let config = RetryConfig::from_env()?;
7201 Self::with_config(config).await
7202 }
7203
7204 /// Creates a new `TokenManager` with the specified retry configuration
7205 ///
7206 /// # Errors
7207 /// Returns an error if the base URL cannot be obtained from environment variables
7208 pub async fn with_config(config: RetryConfig) -> Result<Self, Error> {
7209 let base_url = get_amp_api_base_url()?;
7210 Self::with_config_and_base_url(config, base_url).await
7211 }
7212
7213 /// Creates a new `TokenManager` with the specified configuration and base URL (for testing)
7214 ///
7215 /// # Errors
7216 /// This method is infallible but returns Result for API consistency
7217 pub async fn with_config_and_base_url(
7218 config: RetryConfig,
7219 base_url: Url,
7220 ) -> Result<Self, Error> {
7221 let manager = Self {
7222 token_data: Arc::new(Mutex::new(None)),
7223 retry_client: RetryClient::new(config),
7224 base_url,
7225 token_operation_semaphore: Arc::new(Semaphore::new(1)),
7226 };
7227
7228 // Load token from disk if persistence is enabled
7229 if Self::should_persist_tokens() {
7230 if let Ok(Some(token_data)) = manager.load_token_from_disk().await {
7231 *manager.token_data.lock().await = Some(token_data);
7232 tracing::info!("Token loaded from disk during initialization");
7233 }
7234 }
7235
7236 Ok(manager)
7237 }
7238
7239 /// Creates a new `TokenManager` with a pre-set mock token (for testing)
7240 ///
7241 /// # Errors
7242 /// This method is infallible but returns Result for API consistency
7243 pub fn with_mock_token(
7244 config: RetryConfig,
7245 base_url: Url,
7246 mock_token: String,
7247 ) -> Result<Self, Error> {
7248 let expires_at = Utc::now() + Duration::hours(24); // Mock token valid for 24 hours
7249 let token_data = TokenData::new(mock_token, expires_at);
7250
7251 let manager = Self {
7252 token_data: Arc::new(Mutex::new(Some(token_data))),
7253 retry_client: RetryClient::new(config),
7254 base_url,
7255 token_operation_semaphore: Arc::new(Semaphore::new(1)),
7256 };
7257
7258 Ok(manager)
7259 }
7260
7261 /// Gets a valid authentication token with proactive refresh logic
7262 ///
7263 /// This method implements thread-safe token management logic:
7264 /// 1. Check if a valid token exists and is not expiring soon (within 5 minutes)
7265 /// 2. If token needs refresh/obtain, acquire semaphore to prevent concurrent operations
7266 /// 3. Double-check token state after acquiring semaphore (another thread may have updated it)
7267 /// 4. Perform atomic token update operations
7268 /// 5. Return the valid token
7269 ///
7270 /// # Thread Safety
7271 /// This method is fully thread-safe and prevents race conditions by:
7272 /// - Using a semaphore to ensure only one token operation at a time
7273 /// - Double-checking token state after acquiring the semaphore
7274 /// - Performing atomic token updates within the critical section
7275 ///
7276 /// # Errors
7277 /// Returns a `TokenError` if token acquisition or refresh fails after all retries
7278 pub async fn get_token(&self) -> Result<String, Error> {
7279 // Fast path: check if we have a valid token without acquiring semaphore
7280 if let Some(token) = self.check_existing_token().await? {
7281 return Ok(token);
7282 }
7283
7284 // Slow path: token needs refresh/obtain, acquire semaphore for thread safety
7285 let _permit = self.acquire_token_semaphore().await?;
7286
7287 // Double-check token state after acquiring semaphore - another thread may have updated it
7288 if let Some(token) = self.check_existing_token().await? {
7289 tracing::debug!("Token was updated by another thread, using existing valid token");
7290 return Ok(token);
7291 }
7292
7293 // At this point, we need to refresh or obtain a new token
7294 self.handle_token_refresh_or_obtain().await
7295 }
7296
7297 /// Checks if we have a valid existing token that doesn't expire soon
7298 async fn check_existing_token(&self) -> Result<Option<String>, Error> {
7299 let token_guard = self.token_data.lock().await;
7300 if let Some(ref token_data) = *token_guard {
7301 if !token_data.expires_soon(Duration::minutes(5)) {
7302 tracing::debug!("Using existing valid token");
7303 let token = token_data.token.expose_secret().clone();
7304 drop(token_guard);
7305 return Ok(Some(token));
7306 }
7307 }
7308 drop(token_guard);
7309 Ok(None)
7310 }
7311
7312 /// Acquires the token operation semaphore for thread-safe operations
7313 async fn acquire_token_semaphore(&self) -> Result<tokio::sync::SemaphorePermit<'_>, Error> {
7314 let permit = self
7315 .token_operation_semaphore
7316 .acquire()
7317 .await
7318 .map_err(|e| {
7319 Error::Token(TokenError::storage(format!(
7320 "Failed to acquire token operation semaphore: {e}"
7321 )))
7322 })?;
7323
7324 tracing::debug!("Acquired token operation semaphore for thread-safe token management");
7325 Ok(permit)
7326 }
7327
7328 /// Handles the token refresh or obtain logic
7329 async fn handle_token_refresh_or_obtain(&self) -> Result<String, Error> {
7330 let needs_refresh = self.determine_token_operation().await;
7331
7332 if needs_refresh {
7333 match self.refresh_token_internal().await {
7334 Ok(token) => {
7335 tracing::info!("Token refreshed successfully");
7336 return Ok(token);
7337 }
7338 Err(e) => {
7339 tracing::warn!("Token refresh failed, falling back to obtain: {e}");
7340 // Fall through to obtain new token
7341 }
7342 }
7343 }
7344
7345 // Either we needed to obtain from the start, or refresh failed
7346 self.obtain_token_internal().await
7347 }
7348
7349 /// Determines whether we need to refresh or obtain a new token
7350 async fn determine_token_operation(&self) -> bool {
7351 let token_guard = self.token_data.lock().await;
7352 token_guard.as_ref().map_or_else(
7353 || {
7354 tracing::info!("No token exists, will obtain new token");
7355 false
7356 },
7357 |token_data| {
7358 if token_data.is_expired() {
7359 tracing::info!("Token is expired, will obtain new token");
7360 false
7361 } else {
7362 tracing::info!("Token expires soon, will attempt refresh");
7363 true
7364 }
7365 },
7366 )
7367 }
7368
7369 /// Obtains a new authentication token using environment credentials with retry logic
7370 ///
7371 /// This method:
7372 /// 1. Reads credentials from environment variables
7373 /// 2. Makes a token request with retry logic
7374 /// 3. Stores the new token with 24-hour expiry
7375 /// 4. Returns the token string
7376 ///
7377 /// # Thread Safety
7378 /// This method acquires the token operation semaphore to ensure thread-safe operation.
7379 /// For internal use within already-synchronized contexts, use `obtain_token_internal()`.
7380 ///
7381 /// # Errors
7382 /// Returns an error if:
7383 /// - Environment variables are missing
7384 /// - All retry attempts fail
7385 /// - Response parsing fails
7386 pub async fn obtain_token(&self) -> Result<String, Error> {
7387 let _permit = self
7388 .token_operation_semaphore
7389 .acquire()
7390 .await
7391 .map_err(|e| {
7392 Error::Token(TokenError::storage(format!(
7393 "Failed to acquire token operation semaphore: {e}"
7394 )))
7395 })?;
7396
7397 self.obtain_token_internal().await
7398 }
7399
7400 /// Internal method to obtain a new authentication token without acquiring semaphore
7401 ///
7402 /// This method should only be called from contexts where the token operation semaphore
7403 /// has already been acquired (e.g., from within `get_token()`).
7404 ///
7405 /// # Errors
7406 /// Returns an error if:
7407 /// - Environment variables are missing
7408 /// - All retry attempts fail
7409 /// - Response parsing fails
7410 async fn obtain_token_internal(&self) -> Result<String, Error> {
7411 tracing::debug!("Obtaining new authentication token");
7412
7413 let request_payload = Self::get_credentials_from_env()?;
7414 let url = self.build_obtain_token_url();
7415 let response = self.execute_token_request(&url, &request_payload).await?;
7416 let token_response = self.parse_token_response(response).await?;
7417
7418 self.store_token_data(&token_response.token).await;
7419
7420 tracing::info!("New authentication token obtained successfully");
7421 Ok(token_response.token)
7422 }
7423
7424 /// Gets credentials from environment variables
7425 fn get_credentials_from_env() -> Result<TokenRequest, Error> {
7426 let username = env::var("AMP_USERNAME")
7427 .map_err(|_| Error::MissingEnvVar("AMP_USERNAME".to_string()))?;
7428 let password = env::var("AMP_PASSWORD")
7429 .map_err(|_| Error::MissingEnvVar("AMP_PASSWORD".to_string()))?;
7430
7431 Ok(TokenRequest { username, password })
7432 }
7433
7434 /// Builds the URL for token obtain endpoint
7435 fn build_obtain_token_url(&self) -> Url {
7436 let mut url = self.base_url.clone();
7437 url.path_segments_mut()
7438 .unwrap()
7439 .push("user")
7440 .push("obtain_token");
7441 url
7442 }
7443
7444 /// Executes the token request with retry logic
7445 async fn execute_token_request(
7446 &self,
7447 url: &Url,
7448 request_payload: &TokenRequest,
7449 ) -> Result<reqwest::Response, Error> {
7450 let response = self
7451 .retry_client
7452 .execute_with_retry(|| {
7453 self.retry_client
7454 .client()
7455 .post(url.clone())
7456 .json(request_payload)
7457 })
7458 .await
7459 .map_err(Error::Token)?;
7460
7461 if !response.status().is_success() {
7462 let status = response.status();
7463 let error_text = response
7464 .text()
7465 .await
7466 .unwrap_or_else(|_| "Unknown error".to_string());
7467 return Err(Error::TokenRequestFailed { status, error_text });
7468 }
7469
7470 Ok(response)
7471 }
7472
7473 /// Parses the token response from the API
7474 async fn parse_token_response(
7475 &self,
7476 response: reqwest::Response,
7477 ) -> Result<TokenResponse, Error> {
7478 response
7479 .json()
7480 .await
7481 .map_err(|e| Error::ResponseParsingFailed(e.to_string()))
7482 }
7483
7484 /// Stores the token data with 24-hour expiry and optional disk persistence
7485 async fn store_token_data(&self, token: &str) {
7486 let expires_at = Utc::now() + Duration::days(1);
7487 let token_data = TokenData::new(token.to_string(), expires_at);
7488
7489 // Atomic token update - hold the lock for the minimal time needed
7490 *self.token_data.lock().await = Some(token_data.clone());
7491 tracing::debug!("Token data updated atomically in storage");
7492
7493 // Save to disk if persistence is enabled
7494 if Self::should_persist_tokens() {
7495 if let Err(e) = self.save_token_to_disk(&token_data).await {
7496 tracing::warn!("Failed to save token to disk: {e}");
7497 }
7498 }
7499 }
7500
7501 /// Refreshes the current authentication token with fallback to obtain on failure
7502 ///
7503 /// This method:
7504 /// 1. Uses the existing token to request a refresh
7505 /// 2. Updates the stored token data on success
7506 /// 3. Falls back to obtaining a new token if refresh fails
7507 ///
7508 /// # Thread Safety
7509 /// This method acquires the token operation semaphore to ensure thread-safe operation.
7510 /// For internal use within already-synchronized contexts, use `refresh_token_internal()`.
7511 ///
7512 /// # Errors
7513 /// Returns an error if both refresh and obtain operations fail
7514 pub async fn refresh_token(&self) -> Result<String, Error> {
7515 let _permit = self
7516 .token_operation_semaphore
7517 .acquire()
7518 .await
7519 .map_err(|e| {
7520 Error::Token(TokenError::storage(format!(
7521 "Failed to acquire token operation semaphore: {e}"
7522 )))
7523 })?;
7524
7525 self.refresh_token_internal().await
7526 }
7527
7528 /// Internal method to refresh the current authentication token without acquiring semaphore
7529 ///
7530 /// This method should only be called from contexts where the token operation semaphore
7531 /// has already been acquired (e.g., from within `get_token()`).
7532 ///
7533 /// # Errors
7534 /// Returns an error if both refresh and obtain operations fail
7535 #[allow(clippy::cognitive_complexity)]
7536 async fn refresh_token_internal(&self) -> Result<String, Error> {
7537 tracing::debug!("Refreshing authentication token");
7538
7539 let Some(current_token) = self.get_current_token_for_refresh().await else {
7540 tracing::warn!("No token available for refresh, obtaining new token");
7541 return self.obtain_token_internal().await;
7542 };
7543
7544 let url = self.build_refresh_token_url();
7545 let response = self.execute_refresh_request(&url, ¤t_token).await;
7546
7547 match response {
7548 Ok(resp) => self.handle_refresh_response(resp).await,
7549 Err(e) => {
7550 tracing::warn!("Token refresh request failed: {e}, falling back to obtain");
7551 self.obtain_token_internal().await
7552 }
7553 }
7554 }
7555
7556 /// Gets the current token for refresh operations
7557 async fn get_current_token_for_refresh(&self) -> Option<String> {
7558 let token_guard = self.token_data.lock().await;
7559 token_guard
7560 .as_ref()
7561 .map(|token_data| token_data.token.expose_secret().clone())
7562 }
7563
7564 /// Builds the URL for token refresh endpoint
7565 fn build_refresh_token_url(&self) -> Url {
7566 let mut url = self.base_url.clone();
7567 url.path_segments_mut()
7568 .unwrap()
7569 .push("user")
7570 .push("refresh_token");
7571 url
7572 }
7573
7574 /// Executes the refresh request with retry logic
7575 async fn execute_refresh_request(
7576 &self,
7577 url: &Url,
7578 current_token: &str,
7579 ) -> Result<reqwest::Response, TokenError> {
7580 self.retry_client
7581 .execute_with_retry(|| {
7582 self.retry_client
7583 .client()
7584 .post(url.clone())
7585 .header(AUTHORIZATION, format!("token {current_token}"))
7586 })
7587 .await
7588 }
7589
7590 /// Handles the refresh response, either storing the new token or falling back to obtain
7591 async fn handle_refresh_response(&self, resp: reqwest::Response) -> Result<String, Error> {
7592 if !resp.status().is_success() {
7593 let status = resp.status();
7594 let error_text = resp
7595 .text()
7596 .await
7597 .unwrap_or_else(|_| "Unknown error".to_string());
7598
7599 tracing::warn!("Token refresh failed with status {status}: {error_text}");
7600 return self.obtain_token_internal().await;
7601 }
7602
7603 let token_response: TokenResponse = resp
7604 .json()
7605 .await
7606 .map_err(|e| Error::ResponseParsingFailed(e.to_string()))?;
7607
7608 self.store_token_data(&token_response.token).await;
7609 tracing::info!("Authentication token refreshed successfully");
7610 Ok(token_response.token)
7611 }
7612
7613 /// Gets current token information for debugging and monitoring
7614 ///
7615 /// Returns detailed information about the current token including:
7616 /// - Expiry time and remaining duration
7617 /// - Token age since acquisition
7618 /// - Expiry status flags
7619 ///
7620 /// # Returns
7621 /// `Some(TokenInfo)` if a token exists, `None` if no token is stored
7622 ///
7623 /// # Errors
7624 /// Returns an error if token information retrieval fails
7625 pub async fn get_token_info(&self) -> Result<Option<TokenInfo>, Error> {
7626 tracing::debug!("Retrieving token information for debugging");
7627
7628 let token_info = self.token_data.lock().await.as_ref().map(TokenInfo::from);
7629
7630 match &token_info {
7631 Some(info) => {
7632 tracing::debug!(
7633 "Token info retrieved - expires_at: {}, age: {:?}, expires_in: {:?}, is_expired: {}, expires_soon: {}",
7634 info.expires_at,
7635 info.age,
7636 info.expires_in,
7637 info.is_expired,
7638 info.expires_soon
7639 );
7640 }
7641 None => {
7642 tracing::debug!("No token information available - no token stored");
7643 }
7644 }
7645
7646 Ok(token_info)
7647 }
7648
7649 /// Clears the stored token (useful for testing scenarios)
7650 ///
7651 /// This method removes the current token from storage, forcing the next
7652 /// `get_token()` call to obtain a fresh token.
7653 ///
7654 /// # Errors
7655 /// Returns an error if token clearing fails
7656 pub async fn clear_token(&self) -> Result<(), Error> {
7657 tracing::debug!("Clearing stored token from memory and disk");
7658
7659 let had_token = self.clear_token_from_memory().await;
7660 self.clear_token_from_disk_if_enabled().await;
7661 Self::log_token_clear_result(had_token);
7662
7663 Ok(())
7664 }
7665
7666 /// Clears the token from memory and returns whether a token was present
7667 async fn clear_token_from_memory(&self) -> bool {
7668 let mut token_guard = self.token_data.lock().await;
7669 let had_token = token_guard.is_some();
7670 *token_guard = None;
7671 drop(token_guard);
7672 had_token
7673 }
7674
7675 /// Clears the token from disk if persistence is enabled
7676 async fn clear_token_from_disk_if_enabled(&self) {
7677 if Self::should_persist_tokens() {
7678 if let Err(e) = self.remove_token_from_disk().await {
7679 tracing::warn!("Failed to remove token from disk: {e}");
7680 }
7681 }
7682 }
7683
7684 /// Logs the result of the token clearing operation
7685 fn log_token_clear_result(had_token: bool) {
7686 if had_token {
7687 tracing::info!("Token successfully cleared from memory and disk - next get_token() will obtain fresh token");
7688 } else {
7689 tracing::debug!("No token was stored to clear");
7690 }
7691 }
7692
7693 /// Forces a token refresh regardless of current token status
7694 ///
7695 /// This method bypasses the normal proactive refresh logic and immediately
7696 /// attempts to refresh the current token. If no token exists or refresh fails,
7697 /// it falls back to obtaining a new token.
7698 ///
7699 /// # Thread Safety
7700 /// This method is fully thread-safe and uses the same semaphore-based synchronization
7701 /// as other token operations to prevent race conditions.
7702 ///
7703 /// # Errors
7704 /// Returns an error if both refresh and obtain operations fail
7705 pub async fn force_refresh(&self) -> Result<String, Error> {
7706 tracing::info!("Forcing token refresh - bypassing normal proactive refresh logic");
7707
7708 let _permit = self.acquire_token_semaphore().await?;
7709 self.log_token_status_for_refresh().await;
7710 self.execute_forced_refresh().await
7711 }
7712
7713 /// Logs the current token status for forced refresh operation
7714 async fn log_token_status_for_refresh(&self) {
7715 let has_token = {
7716 let token_guard = self.token_data.lock().await;
7717 token_guard.is_some()
7718 };
7719
7720 if has_token {
7721 tracing::debug!("Existing token found, attempting forced refresh");
7722 } else {
7723 tracing::debug!("No existing token found, will obtain new token");
7724 }
7725 }
7726
7727 /// Executes the forced refresh operation
7728 async fn execute_forced_refresh(&self) -> Result<String, Error> {
7729 match self.refresh_token_internal().await {
7730 Ok(token) => {
7731 tracing::info!("Forced token refresh completed successfully");
7732 Ok(token)
7733 }
7734 Err(e) => {
7735 tracing::error!("Forced token refresh failed: {e}");
7736 Err(e)
7737 }
7738 }
7739 }
7740
7741 /// Determines if token persistence is enabled based on environment variables
7742 ///
7743 /// Token persistence is enabled when:
7744 /// - `AMP_TESTS=live` (for live API testing)
7745 /// - `AMP_TOKEN_PERSISTENCE=true` is set
7746 /// - NOT in mock test environments (to prevent test pollution)
7747 fn should_persist_tokens() -> bool {
7748 // Use the new environment detection logic
7749 let environment = TokenEnvironment::detect();
7750
7751 // Never persist tokens in mock environments to prevent test pollution
7752 if environment.is_mock() {
7753 tracing::debug!("Token persistence disabled - mock environment detected");
7754 return false;
7755 }
7756
7757 // Check if explicitly enabled
7758 if env::var("AMP_TOKEN_PERSISTENCE").unwrap_or_default() == "true" {
7759 tracing::debug!("Token persistence enabled - AMP_TOKEN_PERSISTENCE=true");
7760 return true;
7761 }
7762
7763 // Use environment-based persistence setting
7764 let should_persist = environment.should_persist_tokens();
7765 tracing::debug!(
7766 "Token persistence setting from environment: {}",
7767 should_persist
7768 );
7769 should_persist
7770 }
7771
7772 /// Loads token data from disk if it exists and is valid
7773 async fn load_token_from_disk(&self) -> Result<Option<TokenData>, Error> {
7774 let token_file = "token.json";
7775
7776 if !self.token_file_exists(token_file).await {
7777 return Ok(None);
7778 }
7779
7780 let content = self.read_token_file(token_file).await?;
7781 self.parse_and_validate_token(token_file, &content).await
7782 }
7783
7784 /// Checks if the token file exists on disk
7785 async fn token_file_exists(&self, token_file: &str) -> bool {
7786 tokio::fs::try_exists(token_file).await.map_or_else(
7787 |_| {
7788 tracing::debug!("Error checking token file existence: {}", token_file);
7789 false
7790 },
7791 |exists| {
7792 if !exists {
7793 tracing::debug!("Token file does not exist: {}", token_file);
7794 }
7795 exists
7796 },
7797 )
7798 }
7799
7800 /// Reads the token file content from disk
7801 async fn read_token_file(&self, token_file: &str) -> Result<String, Error> {
7802 use tokio::fs;
7803
7804 match fs::read_to_string(token_file).await {
7805 Ok(content) => Ok(content),
7806 Err(e) => {
7807 tracing::warn!("Failed to read token file: {e}");
7808 Err(Error::Token(TokenError::storage(format!(
7809 "Failed to read token file: {e}"
7810 ))))
7811 }
7812 }
7813 }
7814
7815 /// Parses token content and validates expiration
7816 async fn parse_and_validate_token(
7817 &self,
7818 token_file: &str,
7819 content: &str,
7820 ) -> Result<Option<TokenData>, Error> {
7821 match serde_json::from_str::<TokenData>(content) {
7822 Ok(token_data) => self.handle_parsed_token(token_file, token_data).await,
7823 Err(e) => self.handle_parse_error(token_file, e).await,
7824 }
7825 }
7826
7827 /// Handles successfully parsed token data, checking expiration
7828 async fn handle_parsed_token(
7829 &self,
7830 token_file: &str,
7831 token_data: TokenData,
7832 ) -> Result<Option<TokenData>, Error> {
7833 if token_data.is_expired() {
7834 tracing::info!("Token loaded from disk is expired, removing file");
7835 let _ = tokio::fs::remove_file(token_file).await;
7836 Ok(None)
7837 } else {
7838 tracing::info!("Valid token loaded from disk");
7839 Ok(Some(token_data))
7840 }
7841 }
7842
7843 /// Handles token parsing errors by cleaning up the invalid file
7844 async fn handle_parse_error(
7845 &self,
7846 token_file: &str,
7847 e: serde_json::Error,
7848 ) -> Result<Option<TokenData>, Error> {
7849 tracing::warn!("Failed to parse token file, removing: {e}");
7850 let _ = tokio::fs::remove_file(token_file).await;
7851 Err(Error::Token(TokenError::serialization(format!(
7852 "Failed to parse token file: {e}"
7853 ))))
7854 }
7855
7856 /// Saves token data to disk
7857 async fn save_token_to_disk(&self, token_data: &TokenData) -> Result<(), Error> {
7858 use tokio::fs;
7859
7860 let token_file = "token.json";
7861
7862 match serde_json::to_string_pretty(token_data) {
7863 Ok(json) => match fs::write(token_file, json).await {
7864 Ok(()) => {
7865 tracing::debug!("Token saved to disk: {}", token_file);
7866 Ok(())
7867 }
7868 Err(e) => {
7869 tracing::error!("Failed to write token file: {e}");
7870 Err(Error::Token(TokenError::storage(format!(
7871 "Failed to write token file: {e}"
7872 ))))
7873 }
7874 },
7875 Err(e) => {
7876 tracing::error!("Failed to serialize token data: {e}");
7877 Err(Error::Token(TokenError::serialization(format!(
7878 "Failed to serialize token data: {e}"
7879 ))))
7880 }
7881 }
7882 }
7883
7884 /// Removes the token file from disk
7885 async fn remove_token_from_disk(&self) -> Result<(), Error> {
7886 use tokio::fs;
7887
7888 let token_file = "token.json";
7889
7890 match fs::remove_file(token_file).await {
7891 Ok(()) => {
7892 tracing::debug!("Token file removed from disk: {}", token_file);
7893 Ok(())
7894 }
7895 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7896 tracing::debug!("Token file does not exist, nothing to remove");
7897 Ok(())
7898 }
7899 Err(e) => {
7900 tracing::warn!("Failed to remove token file: {e}");
7901 Err(Error::Token(TokenError::storage(format!(
7902 "Failed to remove token file: {e}"
7903 ))))
7904 }
7905 }
7906 }
7907
7908 /// Forces cleanup of token persistence files (useful for testing)
7909 /// This method removes token files regardless of persistence settings
7910 ///
7911 /// # Errors
7912 /// Returns an error if:
7913 /// - File system permissions prevent deletion of the token file
7914 /// - I/O errors occur during file deletion operations
7915 /// - The token file is locked by another process
7916 pub async fn force_cleanup_token_files() -> Result<(), Error> {
7917 use tokio::fs;
7918
7919 let token_file = "token.json";
7920
7921 match fs::remove_file(token_file).await {
7922 Ok(()) => {
7923 tracing::debug!("Token file forcefully removed: {}", token_file);
7924 Ok(())
7925 }
7926 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
7927 tracing::debug!("No token file to clean up");
7928 Ok(())
7929 }
7930 Err(e) => {
7931 tracing::warn!("Failed to force cleanup token file: {e}");
7932 Err(Error::Token(TokenError::storage(format!(
7933 "Failed to force cleanup token file: {e}"
7934 ))))
7935 }
7936 }
7937 }
7938
7939 /// Resets the global `TokenManager` singleton (useful for testing)
7940 ///
7941 /// This method clears the global singleton instance, forcing the next
7942 /// call to `get_global_instance()` to create a fresh `TokenManager`.
7943 /// Primarily intended for test scenarios where a clean state is needed.
7944 ///
7945 /// # Errors
7946 /// Returns an error if:
7947 /// - Token clearing operations fail during the reset process
7948 /// - File system errors occur when clearing persistent token data
7949 /// - The global instance is in an invalid state that prevents cleanup
7950 pub async fn reset_global_instance() -> Result<(), Error> {
7951 // Clear any existing token from the current global instance
7952 if let Some(manager) = GLOBAL_TOKEN_MANAGER.get() {
7953 let _ = manager.clear_token().await;
7954 }
7955
7956 // Reset the OnceCell to allow a new instance to be created
7957 // Note: OnceCell doesn't have a reset method, so we can't actually reset it
7958 // The best we can do is clear the token from the existing instance
7959 tracing::debug!("Global TokenManager instance token cleared for testing");
7960 Ok(())
7961 }
7962}
7963
7964#[derive(Debug, Clone)]
7965pub struct ApiClient {
7966 client: Client,
7967 base_url: Url,
7968 token_strategy: Arc<Box<dyn TokenStrategy>>,
7969}
7970
7971#[allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
7972impl ApiClient {
7973 /// Creates a new API client with the base URL from environment variables.
7974 ///
7975 /// Automatically selects the appropriate token strategy based on environment detection:
7976 /// - Mock strategy for mock environments (no persistence, isolated tokens)
7977 /// - Live strategy for live environments (full token management with persistence)
7978 ///
7979 /// # Errors
7980 ///
7981 /// Returns an error if:
7982 /// - The `AMP_API_BASE_URL` environment variable contains an invalid URL
7983 /// - Token strategy initialization fails
7984 ///
7985 /// # Examples
7986 /// ```no_run
7987 /// # use amp_rs::ApiClient;
7988 /// # #[tokio::main]
7989 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
7990 /// // Create a new client - automatically detects environment
7991 /// let client = ApiClient::new().await?;
7992 ///
7993 /// // Client is ready to use
7994 /// let assets = client.get_assets().await?;
7995 /// println!("Found {} assets", assets.len());
7996 /// # Ok(())
7997 /// # }
7998 /// ```
7999 pub async fn new() -> Result<Self, Error> {
8000 let base_url = get_amp_api_base_url()?;
8001 let client = Client::new();
8002
8003 // Automatic strategy selection based on environment
8004 let token_strategy = TokenEnvironment::create_auto_strategy(None).await?;
8005
8006 tracing::info!(
8007 "Created ApiClient with {} strategy for base URL: {}",
8008 token_strategy.strategy_type(),
8009 base_url
8010 );
8011
8012 Ok(Self {
8013 client,
8014 base_url,
8015 token_strategy: Arc::new(token_strategy),
8016 })
8017 }
8018
8019 /// Creates a new API client with the specified base URL.
8020 ///
8021 /// Automatically selects the appropriate token strategy based on environment detection.
8022 ///
8023 /// # Errors
8024 ///
8025 /// Returns an error if token strategy initialization fails.
8026 ///
8027 /// # Examples
8028 /// ```no_run
8029 /// # use amp_rs::ApiClient;
8030 /// # use reqwest::Url;
8031 /// # #[tokio::main]
8032 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8033 /// let base_url = Url::parse("https://amp-test.blockstream.com/api")?;
8034 /// let client = ApiClient::with_base_url(base_url).await?;
8035 ///
8036 /// // Client is ready to use with the specified URL
8037 /// let assets = client.get_assets().await?;
8038 /// # Ok(())
8039 /// # }
8040 /// ```
8041 pub async fn with_base_url(base_url: Url) -> Result<Self, Error> {
8042 let client = Client::new();
8043
8044 // Automatic strategy selection based on environment
8045 let token_strategy = TokenEnvironment::create_auto_strategy(None).await?;
8046
8047 tracing::info!(
8048 "Created ApiClient with {} strategy for base URL: {}",
8049 token_strategy.strategy_type(),
8050 base_url
8051 );
8052
8053 Ok(Self {
8054 client,
8055 base_url,
8056 token_strategy: Arc::new(token_strategy),
8057 })
8058 }
8059
8060 /// Creates a new API client with a custom token strategy (useful for testing).
8061 ///
8062 /// # Errors
8063 ///
8064 /// Returns an error if the base URL cannot be obtained from environment variables.
8065 pub fn with_token_strategy(token_strategy: Box<dyn TokenStrategy>) -> Result<Self, Error> {
8066 let base_url = get_amp_api_base_url()?;
8067
8068 tracing::info!(
8069 "Created ApiClient with explicit {} strategy for base URL: {}",
8070 token_strategy.strategy_type(),
8071 base_url
8072 );
8073
8074 Ok(Self {
8075 client: Client::new(),
8076 base_url,
8077 token_strategy: Arc::new(token_strategy),
8078 })
8079 }
8080
8081 /// Creates a new API client with a custom token manager (useful for testing).
8082 ///
8083 /// # Errors
8084 ///
8085 /// Returns an error if the base URL cannot be obtained from environment variables.
8086 pub fn with_token_manager(token_manager: Arc<TokenManager>) -> Result<Self, Error> {
8087 let base_url = get_amp_api_base_url()?;
8088 let token_strategy: Box<dyn TokenStrategy> =
8089 Box::new(LiveTokenStrategy::with_token_manager(token_manager));
8090
8091 tracing::info!(
8092 "Created ApiClient with custom token manager for base URL: {}",
8093 base_url
8094 );
8095
8096 Ok(Self {
8097 client: Client::new(),
8098 base_url,
8099 token_strategy: Arc::new(token_strategy),
8100 })
8101 }
8102
8103 /// Creates a new API client for testing with a mock token strategy that always returns a fixed token.
8104 /// This bypasses all token acquisition and management logic and uses complete isolation.
8105 ///
8106 /// # Errors
8107 ///
8108 /// This method is infallible but returns Result for API consistency.
8109 ///
8110 /// # Examples
8111 /// ```no_run
8112 /// # use amp_rs::ApiClient;
8113 /// # use reqwest::Url;
8114 /// # #[tokio::main]
8115 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8116 /// let base_url = Url::parse("http://localhost:8080/api")?;
8117 /// let client = ApiClient::with_mock_token(base_url, "test_token".to_string())?;
8118 ///
8119 /// // Client will always use "test_token" for authentication
8120 /// let token = client.get_token().await?;
8121 /// assert_eq!(token, "test_token");
8122 /// # Ok(())
8123 /// # }
8124 /// ```
8125 pub fn with_mock_token(base_url: Url, mock_token: String) -> Result<Self, Error> {
8126 let client = Client::new();
8127 let token_strategy: Box<dyn TokenStrategy> = Box::new(MockTokenStrategy::new(mock_token));
8128
8129 tracing::info!(
8130 "Created ApiClient with explicit mock token strategy for base URL: {}",
8131 base_url
8132 );
8133
8134 Ok(Self {
8135 client,
8136 base_url,
8137 token_strategy: Arc::new(token_strategy),
8138 })
8139 }
8140
8141 /// Obtains a new authentication token from the AMP API.
8142 ///
8143 /// **Note**: This method is deprecated in favor of the automatic token management
8144 /// provided by `get_token()`. The `TokenManager` handles token acquisition internally
8145 /// with enhanced retry logic and error handling.
8146 ///
8147 /// # Errors
8148 ///
8149 /// Returns an error if:
8150 /// - The `AMP_USERNAME` or `AMP_PASSWORD` environment variables are not set
8151 /// - The HTTP request fails
8152 /// - The token request is rejected by the server
8153 /// - The response cannot be parsed
8154 #[deprecated(note = "Use get_token() instead - it provides automatic token management")]
8155 pub async fn obtain_amp_token(&self) -> Result<String, Error> {
8156 // Delegate to get_token for backward compatibility
8157 self.get_token().await
8158 }
8159
8160 /// Gets current token information for debugging and monitoring.
8161 ///
8162 /// Returns detailed information about the current token including:
8163 /// - Expiry time and remaining duration
8164 /// - Token age since acquisition
8165 /// - Expiry status flags
8166 ///
8167 /// Note: Mock strategies may return limited or no token information.
8168 ///
8169 /// # Returns
8170 /// `Some(TokenInfo)` if a token exists, `None` if no token is stored or strategy doesn't support info
8171 ///
8172 /// # Errors
8173 /// Returns an error if token information retrieval fails
8174 ///
8175 /// # Examples
8176 /// ```no_run
8177 /// # use amp_rs::ApiClient;
8178 /// # #[tokio::main]
8179 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8180 /// let client = ApiClient::new().await?;
8181 ///
8182 /// if let Some(token_info) = client.get_token_info().await? {
8183 /// println!("Token expires at: {}", token_info.expires_at);
8184 /// println!("Token is expired: {}", token_info.is_expired);
8185 /// } else {
8186 /// println!("No token stored or mock strategy in use");
8187 /// }
8188 /// # Ok(())
8189 /// # }
8190 /// ```
8191 pub async fn get_token_info(&self) -> Result<Option<TokenInfo>, Error> {
8192 // Only live strategies support detailed token information
8193 if let Some(live_strategy) = self
8194 .token_strategy
8195 .as_any()
8196 .downcast_ref::<LiveTokenStrategy>()
8197 {
8198 live_strategy.get_token_info().await
8199 } else {
8200 // Mock strategies don't provide detailed token information
8201 tracing::debug!(
8202 "Token info not available for {} strategy",
8203 self.token_strategy.strategy_type()
8204 );
8205 Ok(None)
8206 }
8207 }
8208
8209 /// Clears the stored token (useful for testing scenarios).
8210 ///
8211 /// This method removes the current token from storage, forcing the next
8212 /// `get_token()` call to obtain a fresh token.
8213 ///
8214 /// # Errors
8215 /// Returns an error if token clearing fails
8216 ///
8217 /// # Examples
8218 /// ```no_run
8219 /// # use amp_rs::ApiClient;
8220 /// # #[tokio::main]
8221 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8222 /// let client = ApiClient::new().await?;
8223 ///
8224 /// // Clear any existing token
8225 /// client.clear_token().await?;
8226 ///
8227 /// // Next get_token() call will obtain a fresh token
8228 /// let token = client.get_token().await?;
8229 /// # Ok(())
8230 /// # }
8231 /// ```
8232 pub async fn clear_token(&self) -> Result<(), Error> {
8233 self.token_strategy.clear_token().await
8234 }
8235
8236 /// Forces a token refresh regardless of current token status.
8237 ///
8238 /// This method bypasses the normal proactive refresh logic and immediately
8239 /// attempts to refresh the current token. If no token exists or refresh fails,
8240 /// it falls back to obtaining a new token.
8241 ///
8242 /// # Errors
8243 /// Returns an error if both refresh and obtain operations fail
8244 pub async fn force_refresh(&self) -> Result<String, Error> {
8245 // Clear current token and get a fresh one
8246 self.token_strategy.clear_token().await?;
8247 self.token_strategy.get_token().await
8248 }
8249
8250 /// Resets the global `TokenManager` singleton (useful for testing).
8251 ///
8252 /// This method clears the token from the global `TokenManager` instance.
8253 /// Primarily intended for test scenarios where a clean token state is needed.
8254 ///
8255 /// # Errors
8256 /// Returns an error if the reset operation fails
8257 pub async fn reset_global_token_manager() -> Result<(), Error> {
8258 TokenManager::reset_global_instance().await
8259 }
8260
8261 /// Gets a valid authentication token with automatic token management.
8262 ///
8263 /// This method uses the integrated `TokenManager` to handle:
8264 /// - Proactive token refresh (5 minutes before expiry)
8265 /// - Automatic fallback from refresh to obtain on failure
8266 /// - Retry logic with exponential backoff
8267 /// - Thread-safe token storage
8268 ///
8269 /// # Errors
8270 ///
8271 /// Returns an error if token acquisition or refresh fails after all retries.
8272 ///
8273 /// # Examples
8274 /// ```no_run
8275 /// # use amp_rs::ApiClient;
8276 /// # #[tokio::main]
8277 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8278 /// let client = ApiClient::new().await?;
8279 ///
8280 /// // Get a valid token - automatically handles refresh if needed
8281 /// let token = client.get_token().await?;
8282 /// println!("Got token: {}", &token[..10]); // Print first 10 chars
8283 /// # Ok(())
8284 /// # }
8285 /// ```
8286 pub async fn get_token(&self) -> Result<String, Error> {
8287 self.token_strategy.get_token().await
8288 }
8289
8290 /// Returns the type of token strategy currently in use
8291 ///
8292 /// This is useful for debugging and testing to verify the correct strategy is selected.
8293 ///
8294 /// # Returns
8295 /// A string indicating the strategy type: "mock" or "live"
8296 #[must_use]
8297 pub fn get_strategy_type(&self) -> &'static str {
8298 self.token_strategy.strategy_type()
8299 }
8300
8301 /// Returns whether the current strategy persists tokens
8302 ///
8303 /// This is useful for understanding the token management behavior.
8304 ///
8305 /// # Returns
8306 /// `true` if tokens are persisted to disk, `false` for in-memory only
8307 #[must_use]
8308 pub fn should_persist_tokens(&self) -> bool {
8309 self.token_strategy.should_persist()
8310 }
8311
8312 /// Force cleanup of token files (for test cleanup)
8313 ///
8314 /// This is a static method that can be used to cleanup token files
8315 /// without needing an `ApiClient` instance. Useful for test teardown.
8316 ///
8317 /// # Errors
8318 /// Returns an error if token file cleanup fails
8319 pub async fn force_cleanup_token_files() -> Result<(), Error> {
8320 // Only cleanup if we're not in a live test environment
8321 let environment = TokenEnvironment::detect();
8322 if !environment.is_live() || environment.is_mock() {
8323 TokenManager::force_cleanup_token_files().await?;
8324 tracing::debug!("Token files cleaned up for non-live environment");
8325 } else {
8326 tracing::debug!("Skipping token file cleanup in live environment");
8327 }
8328 Ok(())
8329 }
8330
8331 async fn request_raw(
8332 &self,
8333 method: Method,
8334 path: &[&str],
8335 body: Option<impl serde::Serialize>,
8336 ) -> Result<reqwest::Response, Error> {
8337 let debug_logging = std::env::var("AMP_DEBUG").is_ok();
8338
8339 if debug_logging {
8340 eprintln!("🌐 HTTP Request: {} /{}", method, path.join("/"));
8341 }
8342
8343 let token = self.get_token().await?;
8344 let mut url = self.base_url.clone();
8345 url.path_segments_mut().unwrap().extend(path);
8346
8347 if debug_logging {
8348 eprintln!("🔗 Full URL: {url}");
8349 }
8350
8351 // Retry logic for network issues
8352 let max_retries = 3;
8353 let mut last_error = None;
8354
8355 for attempt in 1..=max_retries {
8356 if debug_logging && attempt > 1 {
8357 eprintln!("🔄 Retry attempt {attempt} of {max_retries}");
8358 }
8359
8360 let mut request_builder = self
8361 .client
8362 .request(method.clone(), url.clone())
8363 .header(AUTHORIZATION, format!("token {token}"))
8364 .timeout(std::time::Duration::from_secs(60)); // Increase timeout to 60 seconds
8365
8366 if let Some(ref body) = body {
8367 if debug_logging && attempt == 1 {
8368 if let Ok(json_body) = serde_json::to_string_pretty(&body) {
8369 eprintln!(
8370 "📤 Request body ({} bytes):\n{}",
8371 json_body.len(),
8372 json_body
8373 );
8374 } else {
8375 eprintln!("📤 Request body: [serialization failed]");
8376 }
8377 }
8378 request_builder = request_builder.json(&body);
8379 } else if debug_logging && attempt == 1 {
8380 eprintln!("📤 Request body: [empty]");
8381 }
8382
8383 if debug_logging {
8384 eprintln!("🚀 Sending HTTP request (attempt {attempt})...");
8385 }
8386
8387 match request_builder.send().await {
8388 Ok(response) => {
8389 let status = response.status();
8390
8391 if debug_logging {
8392 eprintln!("📥 Response status: {status}");
8393 }
8394
8395 if !status.is_success() {
8396 let error_text = response
8397 .text()
8398 .await
8399 .unwrap_or_else(|_| "Unknown error".to_string());
8400
8401 if debug_logging {
8402 eprintln!("❌ Error response body: {error_text}");
8403 }
8404
8405 return Err(Error::RequestFailed(format!(
8406 "Request to {path:?} failed with status {status}: {error_text}"
8407 )));
8408 }
8409
8410 if debug_logging {
8411 eprintln!("✅ HTTP request successful");
8412 }
8413
8414 return Ok(response);
8415 }
8416 Err(e) => {
8417 if debug_logging {
8418 eprintln!("❌ HTTP request failed (attempt {attempt}): {e:?}");
8419 eprintln!(" Error kind: {:?}", e.is_timeout());
8420 eprintln!(" Is connect error: {}", e.is_connect());
8421 eprintln!(" Is request error: {}", e.is_request());
8422 }
8423
8424 last_error = Some(e);
8425
8426 // Only retry on network/connection errors, not on client errors
8427 if attempt < max_retries {
8428 #[allow(clippy::cast_sign_loss)] // attempt is always positive (1-3)
8429 let delay = std::time::Duration::from_millis((attempt as u64) * 1000);
8430 if debug_logging {
8431 eprintln!("⏳ Waiting {}ms before retry...", delay.as_millis());
8432 }
8433 tokio::time::sleep(delay).await;
8434 }
8435 }
8436 }
8437 }
8438
8439 // If we get here, all retries failed
8440 if debug_logging {
8441 eprintln!("❌ All {max_retries} retry attempts failed");
8442 }
8443
8444 Err(Error::Reqwest(last_error.unwrap()))
8445 }
8446
8447 async fn request_json<T: DeserializeOwned>(
8448 &self,
8449 method: Method,
8450 path: &[&str],
8451 body: Option<impl serde::Serialize>,
8452 ) -> Result<T, Error> {
8453 // Capture request context for better error messages
8454 let method_str = method.to_string();
8455 let mut url = self.base_url.clone();
8456 url.path_segments_mut().unwrap().extend(path);
8457 let endpoint = url.to_string();
8458 let expected_type = std::any::type_name::<T>().to_string();
8459
8460 let response = self.request_raw(method, path, body).await?;
8461
8462 // Try to deserialize, capturing raw response on failure
8463 match response.text().await {
8464 Ok(raw_response) => serde_json::from_str(&raw_response).map_err(|e| {
8465 Error::ResponseDeserializationFailed {
8466 method: method_str,
8467 endpoint,
8468 expected_type,
8469 serde_error: e.to_string(),
8470 raw_response,
8471 }
8472 }),
8473 Err(e) => Err(Error::ResponseParsingFailed(format!(
8474 "Failed to read response body: {e}"
8475 ))),
8476 }
8477 }
8478
8479 async fn request_empty(
8480 &self,
8481 method: Method,
8482 path: &[&str],
8483 body: Option<impl serde::Serialize>,
8484 ) -> Result<(), Error> {
8485 self.request_raw(method, path, body).await?;
8486 Ok(())
8487 }
8488
8489 /// Gets the API changelog.
8490 ///
8491 /// # Errors
8492 ///
8493 /// Returns an error if:
8494 /// - Authentication fails
8495 /// - The HTTP request fails
8496 /// - The server returns an error status
8497 /// - The response cannot be parsed as JSON
8498 pub async fn get_changelog(&self) -> Result<serde_json::Value, Error> {
8499 self.request_json(Method::GET, &["changelog"], None::<&()>)
8500 .await
8501 }
8502
8503 /// Changes the user's password.
8504 ///
8505 /// # Errors
8506 ///
8507 /// Returns an error if:
8508 /// - Authentication fails
8509 /// - The HTTP request fails
8510 /// - The server rejects the password change
8511 /// - The response cannot be parsed
8512 pub async fn user_change_password(
8513 &self,
8514 password: Secret<String>,
8515 ) -> Result<ChangePasswordResponse, Error> {
8516 let request = ChangePasswordRequest {
8517 password: Secret::new(Password(password.expose_secret().clone())),
8518 };
8519 self.request_json(Method::POST, &["user", "change_password"], Some(request))
8520 .await
8521 }
8522
8523 /// Gets a list of all assets.
8524 ///
8525 /// # Errors
8526 ///
8527 /// Returns an error if:
8528 /// - Authentication fails
8529 /// - The HTTP request fails
8530 /// - The server returns an error status
8531 /// - The response cannot be parsed
8532 ///
8533 /// # Examples
8534 /// ```no_run
8535 /// # use amp_rs::ApiClient;
8536 /// # #[tokio::main]
8537 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8538 /// let client = ApiClient::new().await?;
8539 ///
8540 /// let assets = client.get_assets().await?;
8541 /// for asset in assets {
8542 /// println!("Asset: {} ({})", asset.name, asset.ticker.unwrap_or_default());
8543 /// }
8544 /// # Ok(())
8545 /// # }
8546 /// ```
8547 pub async fn get_assets(&self) -> Result<Vec<Asset>, Error> {
8548 self.request_json(Method::GET, &["assets"], None::<&()>)
8549 .await
8550 }
8551
8552 /// Gets a specific asset by UUID.
8553 ///
8554 /// # Errors
8555 ///
8556 /// Returns an error if:
8557 /// - Authentication fails
8558 /// - The HTTP request fails
8559 /// - The asset does not exist
8560 /// - The response cannot be parsed
8561 ///
8562 /// # Examples
8563 /// ```no_run
8564 /// # use amp_rs::ApiClient;
8565 /// # #[tokio::main]
8566 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8567 /// let client = ApiClient::new().await?;
8568 ///
8569 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
8570 /// let asset = client.get_asset(asset_uuid).await?;
8571 ///
8572 /// println!("Asset: {} ({})", asset.name, asset.ticker.unwrap_or_default());
8573 /// println!("Registered: {}, Locked: {}", asset.is_registered, asset.is_locked);
8574 /// # Ok(())
8575 /// # }
8576 /// ```
8577 pub async fn get_asset(&self, asset_uuid: &str) -> Result<Asset, Error> {
8578 self.request_json(Method::GET, &["assets", asset_uuid], None::<&()>)
8579 .await
8580 }
8581
8582 /// Issues a new asset.
8583 ///
8584 /// # Errors
8585 ///
8586 /// Returns an error if:
8587 /// - Authentication fails
8588 /// - The HTTP request fails
8589 /// - The issuance request is invalid
8590 /// - The response cannot be parsed
8591 ///
8592 /// # Examples
8593 /// ```no_run
8594 /// # use amp_rs::{ApiClient, model::IssuanceRequest};
8595 /// # #[tokio::main]
8596 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8597 /// let client = ApiClient::new().await?;
8598 ///
8599 /// let issuance_request = IssuanceRequest {
8600 /// name: "My Token".to_string(),
8601 /// amount: 1000000,
8602 /// destination_address: "vjU2i2EM2viGEzSywpStMPkTX9U9QSDsLSN63kJJYVpxKJZuxaph8v5r5Jf11aqnfBVdjSbrvcJ2pw26".to_string(),
8603 /// domain: "example.com".to_string(),
8604 /// ticker: "MYTKN".to_string(),
8605 /// pubkey: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(),
8606 /// precision: Some(8),
8607 /// is_confidential: Some(true),
8608 /// is_reissuable: Some(false),
8609 /// reissuance_amount: None,
8610 /// reissuance_address: None,
8611 /// transfer_restricted: Some(false),
8612 /// };
8613 ///
8614 /// let response = client.issue_asset(&issuance_request).await?;
8615 /// println!("Issued asset with UUID: {}", response.asset_uuid);
8616 /// # Ok(())
8617 /// # }
8618 /// ```
8619 pub async fn issue_asset(
8620 &self,
8621 issuance_request: &IssuanceRequest,
8622 ) -> Result<IssuanceResponse, Error> {
8623 self.request_json(Method::POST, &["assets", "issue"], Some(issuance_request))
8624 .await
8625 }
8626
8627 /// Edits an existing asset.
8628 ///
8629 /// # Errors
8630 ///
8631 /// Returns an error if:
8632 /// - Authentication fails
8633 /// - The HTTP request fails
8634 /// - The asset does not exist
8635 /// - The edit request is invalid
8636 /// - The response cannot be parsed
8637 pub async fn edit_asset(
8638 &self,
8639 asset_uuid: &str,
8640 edit_asset_request: &EditAssetRequest,
8641 ) -> Result<Asset, Error> {
8642 self.request_json(
8643 Method::PUT,
8644 &["assets", asset_uuid, "edit"],
8645 Some(edit_asset_request),
8646 )
8647 .await
8648 }
8649
8650 /// Registers an asset with the Blockstream Asset Registry.
8651 ///
8652 /// This method publishes an asset to the public registry, making it discoverable
8653 /// and verifiable by other users and applications. The asset must already exist
8654 /// in the AMP system before it can be registered.
8655 ///
8656 /// # Arguments
8657 ///
8658 /// * `asset_uuid` - The unique identifier of the asset to register
8659 ///
8660 /// # Returns
8661 ///
8662 /// Returns a `RegisterAssetResponse` containing:
8663 /// - `success`: Boolean indicating whether the registration was successful
8664 /// - `message`: Optional status message from the API
8665 /// - `asset_id`: The registered asset identifier (hex string)
8666 ///
8667 /// # Errors
8668 ///
8669 /// Returns an error if:
8670 /// - The asset does not exist or cannot be found (404)
8671 /// - Authentication fails or token is invalid (401)
8672 /// - The asset is already registered (returns success with appropriate message)
8673 /// - Network connectivity issues occur
8674 /// - The server returns an error status (5xx)
8675 /// - The response cannot be parsed
8676 ///
8677 /// # Examples
8678 ///
8679 /// ```no_run
8680 /// # use amp_rs::ApiClient;
8681 /// # #[tokio::main]
8682 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8683 /// let client = ApiClient::new().await?;
8684 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
8685 ///
8686 /// let response = client.register_asset(asset_uuid).await?;
8687 /// if response.success {
8688 /// println!("Asset registered successfully!");
8689 /// if let Some(asset) = response.asset_data {
8690 /// println!("Asset ID: {}", asset.asset_id);
8691 /// }
8692 /// if let Some(message) = response.message {
8693 /// println!("Message: {}", message);
8694 /// }
8695 /// }
8696 /// # Ok(())
8697 /// # }
8698 /// ```
8699 pub async fn register_asset(&self, asset_uuid: &str) -> Result<RegisterAssetResponse, Error> {
8700 // Make HTTP request directly to handle both success and error responses
8701 let token = self.get_token().await?;
8702 let mut url = self.base_url.clone();
8703 url.path_segments_mut()
8704 .unwrap()
8705 .extend(&["assets", asset_uuid, "register"]);
8706
8707 let response = self
8708 .client
8709 .request(Method::GET, url)
8710 .header(AUTHORIZATION, format!("token {token}"))
8711 .timeout(std::time::Duration::from_secs(60))
8712 .send()
8713 .await
8714 .map_err(|e| Error::RequestFailed(format!("HTTP request failed: {e}")))?;
8715
8716 let status = response.status();
8717 let response_text = response.text().await.map_err(|e| {
8718 Error::ResponseParsingFailed(format!("Failed to read response body: {e}"))
8719 })?;
8720
8721 // Handle HTTP 200 - success case
8722 if status == reqwest::StatusCode::OK {
8723 // Try to parse as Asset (full registration response)
8724 if let Ok(asset) = serde_json::from_str::<Asset>(&response_text) {
8725 return Ok(RegisterAssetResponse {
8726 success: true,
8727 message: Some("Asset registered successfully".to_string()),
8728 asset_data: Some(asset),
8729 });
8730 }
8731
8732 // If parsing as Asset fails, return success with raw message
8733 return Ok(RegisterAssetResponse {
8734 success: true,
8735 message: Some(response_text),
8736 asset_data: None,
8737 });
8738 }
8739
8740 // Handle error responses
8741 // Try to parse error response as JSON
8742 if let Ok(error_json) = serde_json::from_str::<serde_json::Value>(&response_text) {
8743 // Check for "already registered" error
8744 if let Some(error_msg) = error_json.get("Error").and_then(|e| e.as_str()) {
8745 let error_msg_lower = error_msg.to_lowercase();
8746 if error_msg_lower.contains("already registered") {
8747 return Ok(RegisterAssetResponse {
8748 success: true,
8749 message: Some("Asset is already registered".to_string()),
8750 asset_data: None,
8751 });
8752 }
8753
8754 // Other errors - return as error
8755 return Err(Error::RequestFailed(format!(
8756 "Request to [\"assets\", \"{asset_uuid}\", \"register\"] failed with status {status}: {error_msg}"
8757 )));
8758 }
8759 }
8760
8761 // Fallback error for non-JSON or unexpected responses
8762 Err(Error::RequestFailed(format!(
8763 "Request to [\"assets\", \"{asset_uuid}\", \"register\"] failed with status {status}: {response_text}"
8764 )))
8765 }
8766
8767 /// # Errors
8768 /// Returns an error if:
8769 /// - The asset does not exist or cannot be found
8770 /// - Authentication fails or token is invalid
8771 /// - Network connectivity issues occur
8772 /// - The server returns an error status
8773 pub async fn delete_asset(&self, asset_uuid: &str) -> Result<(), Error> {
8774 self.request_empty(
8775 Method::DELETE,
8776 &["assets", asset_uuid, "delete"],
8777 None::<&()>,
8778 )
8779 .await
8780 }
8781
8782 /// # Errors
8783 /// Returns an error if:
8784 /// - The transaction ID is invalid or not found
8785 /// - Authentication fails or token is invalid
8786 /// - Network connectivity issues occur
8787 /// - The server returns an error status
8788 /// - The response cannot be parsed
8789 pub async fn get_broadcast_status(&self, txid: &str) -> Result<BroadcastResponse, Error> {
8790 self.request_json(Method::GET, &["tx", "broadcast", txid], None::<&()>)
8791 .await
8792 }
8793
8794 /// # Errors
8795 /// Returns an error if:
8796 /// - The transaction hex is invalid or malformed
8797 /// - The transaction is rejected by the network
8798 /// - Authentication fails or token is invalid
8799 /// - Network connectivity issues occur
8800 /// - The server returns an error status
8801 /// - The response cannot be parsed
8802 pub async fn broadcast_transaction(&self, tx_hex: &str) -> Result<BroadcastResponse, Error> {
8803 self.request_json(Method::POST, &["tx", "broadcast"], Some(tx_hex))
8804 .await
8805 }
8806
8807 /// # Errors
8808 /// Returns an error if:
8809 /// - The asset UUID is invalid or not found
8810 /// - The user lacks authorization to register the asset
8811 /// - The asset is already registered
8812 /// - Authentication fails or token is invalid
8813 /// - Network connectivity issues occur
8814 /// - The server returns an error status
8815 /// - The response cannot be parsed
8816 pub async fn register_asset_authorized(&self, asset_uuid: &str) -> Result<Asset, Error> {
8817 self.request_json(
8818 Method::GET,
8819 &["assets", asset_uuid, "register-authorized"],
8820 None::<&()>,
8821 )
8822 .await
8823 }
8824
8825 /// # Errors
8826 /// Returns an error if:
8827 /// - The asset UUID is invalid or not found
8828 /// - The asset is already locked
8829 /// - The user lacks permission to lock the asset
8830 /// - Authentication fails or token is invalid
8831 /// - Network connectivity issues occur
8832 /// - The server returns an error status
8833 /// - The response cannot be parsed
8834 pub async fn lock_asset(&self, asset_uuid: &str) -> Result<Asset, Error> {
8835 self.request_json(Method::PUT, &["assets", asset_uuid, "lock"], None::<&()>)
8836 .await
8837 }
8838
8839 /// # Errors
8840 /// Returns an error if:
8841 /// - The asset UUID is invalid or not found
8842 /// - The asset is not currently locked
8843 /// - The user lacks permission to unlock the asset
8844 /// - Authentication fails or token is invalid
8845 /// - Network connectivity issues occur
8846 /// - The server returns an error status
8847 /// - The response cannot be parsed
8848 pub async fn unlock_asset(&self, asset_uuid: &str) -> Result<Asset, Error> {
8849 self.request_json(Method::PUT, &["assets", asset_uuid, "unlock"], None::<&()>)
8850 .await
8851 }
8852
8853 /// # Errors
8854 /// Returns an error if:
8855 /// - The asset UUID is invalid or not found
8856 /// - The activity parameters are invalid
8857 /// - Authentication fails or token is invalid
8858 /// - Network connectivity issues occur
8859 /// - The server returns an error status
8860 /// - The response cannot be parsed
8861 pub async fn get_asset_activities(
8862 &self,
8863 asset_uuid: &str,
8864 params: &AssetActivityParams,
8865 ) -> Result<Vec<Activity>, Error> {
8866 self.request_json(
8867 Method::GET,
8868 &["assets", asset_uuid, "activities"],
8869 Some(params),
8870 )
8871 .await
8872 }
8873
8874 /// # Errors
8875 /// Returns an error if:
8876 /// - The asset UUID is invalid or not found
8877 /// - The specified height is invalid or out of range
8878 /// - Authentication fails or token is invalid
8879 /// - Network connectivity issues occur
8880 /// - The server returns an error status
8881 /// - The response cannot be parsed
8882 pub async fn get_asset_ownerships(
8883 &self,
8884 asset_uuid: &str,
8885 height: Option<i64>,
8886 ) -> Result<Vec<Ownership>, Error> {
8887 let mut path = vec!["assets", asset_uuid, "ownerships"];
8888 let height_str;
8889 if let Some(h) = height {
8890 height_str = h.to_string();
8891 path.push(&height_str);
8892 }
8893 self.request_json(Method::GET, &path, None::<&()>).await
8894 }
8895
8896 /// # Errors
8897 /// Returns an error if:
8898 /// - The asset UUID is invalid or not found
8899 /// - Authentication fails or token is invalid
8900 /// - Network connectivity issues occur
8901 /// - The server returns an error status
8902 /// - The response cannot be parsed
8903 pub async fn get_asset_balance(&self, asset_uuid: &str) -> Result<Balance, Error> {
8904 self.request_json(Method::GET, &["assets", asset_uuid, "balance"], None::<&()>)
8905 .await
8906 }
8907
8908 /// # Errors
8909 /// Returns an error if:
8910 /// - The asset UUID is invalid or not found
8911 /// - Authentication fails or token is invalid
8912 /// - Network connectivity issues occur
8913 /// - The server returns an error status
8914 /// - The response cannot be parsed
8915 pub async fn get_asset_summary(&self, asset_uuid: &str) -> Result<AssetSummary, Error> {
8916 self.request_json(Method::GET, &["assets", asset_uuid, "summary"], None::<&()>)
8917 .await
8918 }
8919
8920 /// # Errors
8921 /// Returns an error if:
8922 /// - The asset UUID is invalid or not found
8923 /// - Authentication fails or token is invalid
8924 /// - Network connectivity issues occur
8925 /// - The server returns an error status
8926 /// - The response cannot be parsed
8927 pub async fn get_asset_utxos(&self, asset_uuid: &str) -> Result<Vec<Utxo>, Error> {
8928 self.request_json(Method::GET, &["assets", asset_uuid, "utxos"], None::<&()>)
8929 .await
8930 }
8931
8932 /// Gets the memo for a specific asset.
8933 ///
8934 /// # Arguments
8935 /// * `asset_uuid` - The UUID of the asset to retrieve the memo for
8936 ///
8937 /// # Returns
8938 /// The memo string associated with the asset
8939 ///
8940 /// # Errors
8941 /// Returns an error if:
8942 /// - Authentication fails
8943 /// - The HTTP request fails
8944 /// - The server returns an error status
8945 /// - The asset does not exist
8946 /// - The response cannot be parsed
8947 pub async fn get_asset_memo(&self, asset_uuid: &str) -> Result<String, Error> {
8948 self.request_json(Method::GET, &["assets", asset_uuid, "memo"], None::<&()>)
8949 .await
8950 }
8951
8952 /// Sets a memo for the specified asset.
8953 ///
8954 /// # Arguments
8955 /// * `asset_uuid` - The UUID of the asset to set the memo for
8956 /// * `memo` - The memo string to associate with the asset
8957 ///
8958 /// # Returns
8959 /// Returns `Ok(())` on success.
8960 ///
8961 /// # Errors
8962 /// Returns an error if:
8963 /// - Authentication fails
8964 /// - The HTTP request fails
8965 /// - The server returns an error status
8966 /// - The asset does not exist
8967 /// - The memo cannot be set due to validation errors
8968 ///
8969 /// # Example
8970 /// ```no_run
8971 /// # use amp_rs::ApiClient;
8972 /// # #[tokio::main]
8973 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
8974 /// let client = ApiClient::new().await?;
8975 /// client.set_asset_memo("asset-uuid-123", "This is a memo for the asset").await?;
8976 /// # Ok(())
8977 /// # }
8978 /// ```
8979 pub async fn set_asset_memo(&self, asset_uuid: &str, memo: &str) -> Result<(), Error> {
8980 let token = self.get_token().await?;
8981 let mut url = self.base_url.clone();
8982 url.path_segments_mut()
8983 .unwrap()
8984 .extend(&["assets", asset_uuid, "memo", "set"]);
8985
8986 let response = self
8987 .client
8988 .request(Method::POST, url)
8989 .header(AUTHORIZATION, format!("token {token}"))
8990 .header("content-type", "application/json")
8991 .body(format!("\"{}\"", memo.replace('"', "\\\"")))
8992 .send()
8993 .await?;
8994
8995 if !response.status().is_success() {
8996 let status = response.status();
8997 let error_text = response
8998 .text()
8999 .await
9000 .unwrap_or_else(|_| "Unknown error".to_string());
9001 return Err(Error::RequestFailed(format!(
9002 "Request to [\"assets\", \"{asset_uuid}\", \"memo\", \"set\"] failed with status {status}: {error_text}"
9003 )));
9004 }
9005
9006 Ok(())
9007 }
9008
9009 /// Blacklists specific UTXOs for an asset to prevent them from being used in transactions.
9010 ///
9011 /// This method adds the specified UTXOs to the asset's blacklist, preventing them from being
9012 /// used in future transactions. This is typically used for security purposes when UTXOs are
9013 /// suspected to be compromised or need to be temporarily disabled.
9014 ///
9015 /// # Arguments
9016 /// * `asset_uuid` - The UUID of the asset to blacklist UTXOs for
9017 /// * `utxos` - A slice of `Outpoint` structs representing the UTXOs to blacklist
9018 ///
9019 /// # Returns
9020 /// Returns a vector of `Utxo` structs representing the blacklisted UTXOs with their updated status.
9021 ///
9022 /// # Errors
9023 /// Returns an error if:
9024 /// - Authentication fails or insufficient permissions
9025 /// - The asset UUID is invalid or does not exist
9026 /// - One or more UTXOs are invalid or already blacklisted
9027 /// - The HTTP request fails
9028 /// - The server returns an error status
9029 /// - The response cannot be parsed
9030 ///
9031 /// # Examples
9032 /// ```no_run
9033 /// # use amp_rs::{ApiClient, model::Outpoint};
9034 /// # #[tokio::main]
9035 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9036 /// let client = ApiClient::new().await?;
9037 ///
9038 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
9039 /// let utxos = vec![
9040 /// Outpoint {
9041 /// txid: "abc123...".to_string(),
9042 /// vout: 0,
9043 /// },
9044 /// Outpoint {
9045 /// txid: "def456...".to_string(),
9046 /// vout: 1,
9047 /// },
9048 /// ];
9049 ///
9050 /// let blacklisted_utxos = client.blacklist_asset_utxos(asset_uuid, &utxos).await?;
9051 /// println!("Blacklisted {} UTXOs", blacklisted_utxos.len());
9052 /// # Ok(())
9053 /// # }
9054 /// ```
9055 ///
9056 /// # Related Methods
9057 /// - [`whitelist_asset_utxos`](Self::whitelist_asset_utxos) - Remove UTXOs from blacklist
9058 /// - [`get_asset`](Self::get_asset) - Get asset information including UTXO status
9059 pub async fn blacklist_asset_utxos(
9060 &self,
9061 asset_uuid: &str,
9062 utxos: &[Outpoint],
9063 ) -> Result<Vec<Utxo>, Error> {
9064 self.request_json(
9065 Method::POST,
9066 &["assets", asset_uuid, "utxos", "blacklist"],
9067 Some(utxos),
9068 )
9069 .await
9070 }
9071
9072 /// Removes UTXOs from the asset's blacklist, allowing them to be used in transactions again.
9073 ///
9074 /// This method removes the specified UTXOs from the asset's blacklist, restoring their ability
9075 /// to be used in transactions. This is the reverse operation of blacklisting UTXOs.
9076 ///
9077 /// # Arguments
9078 /// * `asset_uuid` - The UUID of the asset to whitelist UTXOs for
9079 /// * `utxos` - A slice of `Outpoint` structs representing the UTXOs to remove from blacklist
9080 ///
9081 /// # Returns
9082 /// Returns a vector of `Utxo` structs representing the whitelisted UTXOs with their updated status.
9083 ///
9084 /// # Errors
9085 /// Returns an error if:
9086 /// - Authentication fails or insufficient permissions
9087 /// - The asset UUID is invalid or does not exist
9088 /// - One or more UTXOs are invalid or not currently blacklisted
9089 /// - The HTTP request fails
9090 /// - The server returns an error status
9091 /// - The response cannot be parsed
9092 ///
9093 /// # Examples
9094 /// ```no_run
9095 /// # use amp_rs::{ApiClient, model::Outpoint};
9096 /// # #[tokio::main]
9097 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9098 /// let client = ApiClient::new().await?;
9099 ///
9100 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
9101 /// let utxos = vec![
9102 /// Outpoint {
9103 /// txid: "abc123...".to_string(),
9104 /// vout: 0,
9105 /// },
9106 /// ];
9107 ///
9108 /// let whitelisted_utxos = client.whitelist_asset_utxos(asset_uuid, &utxos).await?;
9109 /// println!("Whitelisted {} UTXOs", whitelisted_utxos.len());
9110 /// # Ok(())
9111 /// # }
9112 /// ```
9113 ///
9114 /// # Related Methods
9115 /// - [`blacklist_asset_utxos`](Self::blacklist_asset_utxos) - Add UTXOs to blacklist
9116 /// - [`get_asset`](Self::get_asset) - Get asset information including UTXO status
9117 pub async fn whitelist_asset_utxos(
9118 &self,
9119 asset_uuid: &str,
9120 utxos: &[Outpoint],
9121 ) -> Result<Vec<Utxo>, Error> {
9122 self.request_json(
9123 Method::POST,
9124 &["assets", asset_uuid, "utxos", "whitelist"],
9125 Some(utxos),
9126 )
9127 .await
9128 }
9129
9130 /// Gets the treasury addresses for a specific asset
9131 ///
9132 /// # Arguments
9133 /// * `asset_uuid` - The UUID of the asset to get treasury addresses for
9134 ///
9135 /// # Returns
9136 /// A vector of treasury addresses as strings
9137 ///
9138 /// # Errors
9139 /// Returns an error if:
9140 /// - The asset does not exist
9141 /// - The request fails
9142 /// - The response cannot be parsed
9143 pub async fn get_asset_treasury_addresses(
9144 &self,
9145 asset_uuid: &str,
9146 ) -> Result<Vec<String>, Error> {
9147 self.request_json(
9148 Method::GET,
9149 &["assets", asset_uuid, "treasury-addresses"],
9150 None::<&()>,
9151 )
9152 .await
9153 }
9154
9155 /// Adds treasury addresses to a specific asset
9156 ///
9157 /// # Arguments
9158 /// * `asset_uuid` - The UUID of the asset to add treasury addresses to
9159 /// * `addresses` - A slice of address strings to add as treasury addresses
9160 ///
9161 /// # Returns
9162 /// Returns `Ok(())` on success
9163 ///
9164 /// # Errors
9165 /// Returns an error if:
9166 /// - The asset does not exist
9167 /// - The addresses are invalid
9168 /// - The request fails
9169 /// - Insufficient permissions
9170 pub async fn add_asset_treasury_addresses(
9171 &self,
9172 asset_uuid: &str,
9173 addresses: &[String],
9174 ) -> Result<(), Error> {
9175 self.request_empty(
9176 Method::POST,
9177 &["assets", asset_uuid, "treasury-addresses", "add"],
9178 Some(addresses),
9179 )
9180 .await
9181 }
9182
9183 /// Removes treasury addresses from a specific asset.
9184 ///
9185 /// This method removes the specified addresses from the asset's treasury address list.
9186 /// Treasury addresses are special addresses that can be used for asset management operations
9187 /// such as reissuance and burning.
9188 ///
9189 /// # Arguments
9190 /// * `asset_uuid` - The UUID of the asset to remove treasury addresses from
9191 /// * `addresses` - A slice of address strings to remove from the treasury addresses
9192 ///
9193 /// # Returns
9194 /// Returns `Ok(())` on successful removal.
9195 ///
9196 /// # Errors
9197 /// Returns an error if:
9198 /// - Authentication fails or insufficient permissions
9199 /// - The asset UUID is invalid or does not exist
9200 /// - One or more addresses are invalid or not currently treasury addresses
9201 /// - The HTTP request fails
9202 /// - The server returns an error status
9203 /// - Attempting to remove the last treasury address (if not allowed)
9204 ///
9205 /// # Examples
9206 /// ```no_run
9207 /// # use amp_rs::ApiClient;
9208 /// # #[tokio::main]
9209 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9210 /// let client = ApiClient::new().await?;
9211 ///
9212 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
9213 /// let addresses = vec![
9214 /// "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string(),
9215 /// "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string(),
9216 /// ];
9217 ///
9218 /// client.delete_asset_treasury_addresses(asset_uuid, &addresses).await?;
9219 /// println!("Removed {} treasury addresses", addresses.len());
9220 /// # Ok(())
9221 /// # }
9222 /// ```
9223 ///
9224 /// # Related Methods
9225 /// - [`add_asset_treasury_addresses`](Self::add_asset_treasury_addresses) - Add treasury addresses
9226 /// - [`get_asset_treasury_addresses`](Self::get_asset_treasury_addresses) - Get current treasury addresses
9227 /// - [`reissue_asset`](Self::reissue_asset) - Reissue assets using treasury addresses
9228 pub async fn delete_asset_treasury_addresses(
9229 &self,
9230 asset_uuid: &str,
9231 addresses: &[String],
9232 ) -> Result<(), Error> {
9233 self.request_empty(
9234 Method::DELETE,
9235 &["assets", asset_uuid, "treasury-addresses", "delete"],
9236 Some(addresses),
9237 )
9238 .await
9239 }
9240
9241 /// Gets a list of all registered users.
9242 ///
9243 /// # Errors
9244 /// Returns an error if:
9245 /// - Authentication fails
9246 /// - The HTTP request fails
9247 /// - The server returns an error status
9248 /// - The response cannot be parsed
9249 ///
9250 /// # Examples
9251 /// ```no_run
9252 /// # use amp_rs::ApiClient;
9253 /// # #[tokio::main]
9254 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9255 /// let client = ApiClient::new().await?;
9256 ///
9257 /// let users = client.get_registered_users().await?;
9258 /// for user in users {
9259 /// println!("User: {} (ID: {})", user.name, user.id);
9260 /// }
9261 /// # Ok(())
9262 /// # }
9263 /// ```
9264 pub async fn get_registered_users(
9265 &self,
9266 ) -> Result<Vec<crate::model::RegisteredUserResponse>, Error> {
9267 self.request_json(Method::GET, &["registered_users"], None::<&()>)
9268 .await
9269 }
9270
9271 /// Gets a specific registered user by ID.
9272 ///
9273 /// # Arguments
9274 /// * `user_id` - The ID of the registered user to retrieve
9275 ///
9276 /// # Errors
9277 /// Returns an error if:
9278 /// - Authentication fails
9279 /// - The HTTP request fails
9280 /// - The user ID does not exist
9281 /// - The response cannot be parsed
9282 ///
9283 /// # Examples
9284 /// ```no_run
9285 /// # use amp_rs::ApiClient;
9286 /// # #[tokio::main]
9287 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9288 /// let client = ApiClient::new().await?;
9289 ///
9290 /// let user = client.get_registered_user(1).await?;
9291 /// println!("User: {} (ID: {})", user.name, user.id);
9292 /// # Ok(())
9293 /// # }
9294 /// ```
9295 pub async fn get_registered_user(
9296 &self,
9297 user_id: i64,
9298 ) -> Result<crate::model::RegisteredUserResponse, Error> {
9299 self.request_json(
9300 Method::GET,
9301 &["registered_users", &user_id.to_string()],
9302 None::<&()>,
9303 )
9304 .await
9305 }
9306
9307 /// Creates a new registered user in the AMP system.
9308 ///
9309 /// This method creates a new registered user with the provided information. Registered users
9310 /// can be associated with GAIDs, assigned to categories, and receive asset assignments.
9311 ///
9312 /// # Arguments
9313 /// * `new_user` - A `RegisteredUserAdd` struct containing the user information to create
9314 ///
9315 /// # Returns
9316 /// Returns a `RegisteredUserResponse` containing the created user's information including
9317 /// the assigned user ID.
9318 ///
9319 /// # Errors
9320 /// Returns an error if:
9321 /// - Authentication fails or insufficient permissions
9322 /// - The user data is invalid (e.g., missing required fields, invalid email format)
9323 /// - A user with the same identifier already exists
9324 /// - The HTTP request fails
9325 /// - The server returns an error status
9326 /// - The response cannot be parsed
9327 ///
9328 /// # Examples
9329 /// ```no_run
9330 /// # use amp_rs::{ApiClient, model::RegisteredUserAdd};
9331 /// # #[tokio::main]
9332 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9333 /// let client = ApiClient::new().await?;
9334 ///
9335 /// let new_user = RegisteredUserAdd {
9336 /// name: "John Doe".to_string(),
9337 /// gaid: Some("GAbYScu6jkWUND2jo3L4KJxyvo55d".to_string()),
9338 /// is_company: false,
9339 /// };
9340 ///
9341 /// let created_user = client.add_registered_user(&new_user).await?;
9342 /// println!("Created user: {} with ID {}", created_user.name, created_user.id);
9343 /// # Ok(())
9344 /// # }
9345 /// ```
9346 ///
9347 /// # Related Methods
9348 /// - [`get_registered_users`](Self::get_registered_users) - List all registered users
9349 /// - [`edit_registered_user`](Self::edit_registered_user) - Update user information
9350 /// - [`delete_registered_user`](Self::delete_registered_user) - Remove a user
9351 pub async fn add_registered_user(
9352 &self,
9353 new_user: &crate::model::RegisteredUserAdd,
9354 ) -> Result<crate::model::RegisteredUserResponse, Error> {
9355 self.request_json(Method::POST, &["registered_users", "add"], Some(new_user))
9356 .await
9357 }
9358
9359 /// Removes a registered user from the AMP system.
9360 ///
9361 /// This method permanently deletes a registered user and all associated data. This operation
9362 /// cannot be undone. Any GAIDs associated with the user will be disassociated, and any
9363 /// pending assignments may be affected.
9364 ///
9365 /// # Arguments
9366 /// * `user_id` - The ID of the registered user to delete
9367 ///
9368 /// # Returns
9369 /// Returns `Ok(())` on successful deletion.
9370 ///
9371 /// # Errors
9372 /// Returns an error if:
9373 /// - Authentication fails or insufficient permissions
9374 /// - The user ID is invalid or does not exist
9375 /// - The user has active assignments that prevent deletion
9376 /// - The HTTP request fails
9377 /// - The server returns an error status
9378 ///
9379 /// # Examples
9380 /// ```no_run
9381 /// # use amp_rs::ApiClient;
9382 /// # #[tokio::main]
9383 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9384 /// let client = ApiClient::new().await?;
9385 ///
9386 /// let user_id = 123;
9387 /// client.delete_registered_user(user_id).await?;
9388 /// println!("Successfully deleted user with ID {}", user_id);
9389 /// # Ok(())
9390 /// # }
9391 /// ```
9392 ///
9393 /// # Related Methods
9394 /// - [`get_registered_user`](Self::get_registered_user) - Get user information before deletion
9395 /// - [`add_registered_user`](Self::add_registered_user) - Create a new user
9396 /// - [`get_registered_user_summary`](Self::get_registered_user_summary) - Check user's assignments
9397 pub async fn delete_registered_user(&self, user_id: i64) -> Result<(), Error> {
9398 self.request_empty(
9399 Method::DELETE,
9400 &["registered_users", &user_id.to_string(), "delete"],
9401 None::<&()>,
9402 )
9403 .await
9404 }
9405
9406 /// Updates registered user information.
9407 ///
9408 /// This method allows you to modify the information of an existing registered user.
9409 /// Only the fields provided in the edit data will be updated; other fields remain unchanged.
9410 ///
9411 /// # Arguments
9412 /// * `registered_user_id` - The ID of the registered user to update
9413 /// * `edit_data` - A `RegisteredUserEdit` struct containing the fields to update
9414 ///
9415 /// # Returns
9416 /// Returns a `RegisteredUserResponse` containing the updated user information.
9417 ///
9418 /// # Errors
9419 /// Returns an error if:
9420 /// - Authentication fails or insufficient permissions
9421 /// - The user ID is invalid or does not exist
9422 /// - The edit data contains invalid values (e.g., invalid email format)
9423 /// - The HTTP request fails
9424 /// - The server returns an error status
9425 /// - The response cannot be parsed
9426 ///
9427 /// # Examples
9428 /// ```no_run
9429 /// # use amp_rs::{ApiClient, model::RegisteredUserEdit};
9430 /// # #[tokio::main]
9431 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9432 /// let client = ApiClient::new().await?;
9433 ///
9434 /// let user_id = 123;
9435 /// let edit_data = RegisteredUserEdit {
9436 /// name: Some("Jane Doe".to_string()),
9437 /// };
9438 ///
9439 /// let updated_user = client.edit_registered_user(user_id, &edit_data).await?;
9440 /// println!("Updated user: {}", updated_user.name);
9441 /// # Ok(())
9442 /// # }
9443 /// ```
9444 ///
9445 /// # Related Methods
9446 /// - [`get_registered_user`](Self::get_registered_user) - Get current user information
9447 /// - [`add_registered_user`](Self::add_registered_user) - Create a new user
9448 /// - [`delete_registered_user`](Self::delete_registered_user) - Remove a user
9449 pub async fn edit_registered_user(
9450 &self,
9451 registered_user_id: i64,
9452 edit_data: &crate::model::RegisteredUserEdit,
9453 ) -> Result<crate::model::RegisteredUserResponse, Error> {
9454 self.request_json(
9455 Method::PUT,
9456 &["registered_users", ®istered_user_id.to_string(), "edit"],
9457 Some(edit_data),
9458 )
9459 .await
9460 }
9461
9462 /// Gets comprehensive summary information for a registered user including assets and distributions.
9463 ///
9464 /// This method retrieves detailed summary information about a registered user, including
9465 /// their basic information, associated assets, assignment history, and distribution records.
9466 /// This provides a complete overview of the user's activity and holdings in the system.
9467 ///
9468 /// # Arguments
9469 /// * `registered_user_id` - The ID of the registered user to get summary for
9470 ///
9471 /// # Returns
9472 /// Returns a `RegisteredUserSummary` containing:
9473 /// - Basic user information (name, email, etc.)
9474 /// - List of associated GAIDs
9475 /// - Asset assignments and their status
9476 /// - Distribution history
9477 /// - Balance information
9478 /// - Activity timestamps
9479 ///
9480 /// # Errors
9481 /// Returns an error if:
9482 /// - Authentication fails or insufficient permissions
9483 /// - The user ID is invalid or does not exist
9484 /// - The HTTP request fails
9485 /// - The server returns an error status
9486 /// - The response cannot be parsed
9487 ///
9488 /// # Examples
9489 /// ```no_run
9490 /// # use amp_rs::ApiClient;
9491 /// # #[tokio::main]
9492 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9493 /// let client = ApiClient::new().await?;
9494 ///
9495 /// let user_id = 123;
9496 /// let summary = client.get_registered_user_summary(user_id).await?;
9497 ///
9498 /// println!("Asset UUID: {}", summary.asset_uuid);
9499 /// println!("Asset ID: {}", summary.asset_id);
9500 /// println!("Asset assignments: {}", summary.assignments.len());
9501 /// println!("Distributions received: {}", summary.distributions.len());
9502 /// # Ok(())
9503 /// # }
9504 /// ```
9505 ///
9506 /// # Related Methods
9507 /// - [`get_registered_user`](Self::get_registered_user) - Get basic user information
9508 /// - [`get_registered_user_gaids`](Self::get_registered_user_gaids) - Get only GAIDs
9509 /// - [`get_asset_assignments`](Self::get_asset_assignments) - Get assignments for specific asset
9510 pub async fn get_registered_user_summary(
9511 &self,
9512 registered_user_id: i64,
9513 ) -> Result<crate::model::RegisteredUserSummary, Error> {
9514 self.request_json(
9515 Method::GET,
9516 &[
9517 "registered_users",
9518 ®istered_user_id.to_string(),
9519 "summary",
9520 ],
9521 None::<&()>,
9522 )
9523 .await
9524 }
9525
9526 /// Gets all GAIDs (Green Address IDs) associated with a registered user.
9527 ///
9528 /// This method retrieves a list of all GAIDs that are currently associated with the specified
9529 /// registered user. GAIDs are unique identifiers that can be used to receive assets and
9530 /// track ownership.
9531 ///
9532 /// # Arguments
9533 /// * `registered_user_id` - The ID of the registered user to get GAIDs for
9534 ///
9535 /// # Returns
9536 /// Returns a vector of GAID strings associated with the user.
9537 ///
9538 /// # Errors
9539 /// Returns an error if:
9540 /// - Authentication fails or insufficient permissions
9541 /// - The user ID is invalid or does not exist
9542 /// - The HTTP request fails
9543 /// - The server returns an error status
9544 /// - The response cannot be parsed
9545 ///
9546 /// # Examples
9547 /// ```no_run
9548 /// # use amp_rs::ApiClient;
9549 /// # #[tokio::main]
9550 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9551 /// let client = ApiClient::new().await?;
9552 ///
9553 /// let user_id = 123;
9554 /// let gaids = client.get_registered_user_gaids(user_id).await?;
9555 ///
9556 /// println!("User {} has {} associated GAIDs:", user_id, gaids.len());
9557 /// for gaid in gaids {
9558 /// println!(" - {}", gaid);
9559 /// }
9560 /// # Ok(())
9561 /// # }
9562 /// ```
9563 ///
9564 /// # Related Methods
9565 /// - [`add_gaid_to_registered_user`](Self::add_gaid_to_registered_user) - Associate a GAID with user
9566 /// - [`set_default_gaid_for_registered_user`](Self::set_default_gaid_for_registered_user) - Set default GAID
9567 /// - [`get_gaid_registered_user`](Self::get_gaid_registered_user) - Find user by GAID
9568 /// - [`validate_gaid`](Self::validate_gaid) - Validate GAID format
9569 pub async fn get_registered_user_gaids(
9570 &self,
9571 registered_user_id: i64,
9572 ) -> Result<Vec<String>, Error> {
9573 self.request_json(
9574 Method::GET,
9575 &["registered_users", ®istered_user_id.to_string(), "gaids"],
9576 None::<&()>,
9577 )
9578 .await
9579 }
9580
9581 /// Associates a GAID with a registered user.
9582 ///
9583 /// # Arguments
9584 /// * `registered_user_id` - The ID of the registered user
9585 /// * `gaid` - The GAID to associate with the user
9586 ///
9587 /// # Errors
9588 ///
9589 /// Returns an error if:
9590 /// - Authentication fails
9591 /// - The HTTP request fails
9592 /// - The server returns an error status
9593 /// - The registered user ID is invalid
9594 /// - The GAID is invalid or already associated
9595 pub async fn add_gaid_to_registered_user(
9596 &self,
9597 registered_user_id: i64,
9598 gaid: &str,
9599 ) -> Result<(), Error> {
9600 // Send GAID as a plain string, not wrapped in an object
9601 self.request_empty(
9602 Method::POST,
9603 &[
9604 "registered_users",
9605 ®istered_user_id.to_string(),
9606 "gaids",
9607 "add",
9608 ],
9609 Some(gaid),
9610 )
9611 .await
9612 }
9613
9614 /// Sets an existing GAID as the default for a registered user.
9615 ///
9616 /// This method allows you to designate a specific GAID as the primary/default
9617 /// GAID for a registered user. The GAID must already be associated with the user.
9618 ///
9619 /// # Arguments
9620 /// * `registered_user_id` - The ID of the registered user
9621 /// * `gaid` - The GAID to set as default
9622 ///
9623 /// # Returns
9624 /// Returns `Ok(())` if the operation is successful.
9625 ///
9626 /// # Errors
9627 /// Returns an error if:
9628 /// - Authentication fails
9629 /// - The HTTP request fails
9630 /// - The server returns an error status
9631 /// - The registered user ID is invalid
9632 /// - The GAID is not associated with the user
9633 pub async fn set_default_gaid_for_registered_user(
9634 &self,
9635 registered_user_id: i64,
9636 gaid: &str,
9637 ) -> Result<(), Error> {
9638 // Send GAID as a plain string, not wrapped in an object
9639 self.request_empty(
9640 Method::POST,
9641 &[
9642 "registered_users",
9643 ®istered_user_id.to_string(),
9644 "gaids",
9645 "set-default",
9646 ],
9647 Some(gaid),
9648 )
9649 .await
9650 }
9651
9652 /// Retrieves the registered user associated with a GAID
9653 ///
9654 /// # Arguments
9655 /// * `gaid` - The GAID to look up
9656 ///
9657 /// # Returns
9658 /// Returns the registered user data if the GAID is associated with a user
9659 ///
9660 /// # Errors
9661 /// This function will return an error if:
9662 /// - The GAID has no associated user
9663 /// - The GAID is invalid
9664 /// - Network or authentication errors occur
9665 pub async fn get_gaid_registered_user(
9666 &self,
9667 gaid: &str,
9668 ) -> Result<crate::model::RegisteredUserResponse, Error> {
9669 self.request_json(
9670 Method::GET,
9671 &["gaids", gaid, "registered_user"],
9672 None::<&()>,
9673 )
9674 .await
9675 }
9676
9677 /// Gets the balance information for a specific GAID.
9678 ///
9679 /// This method retrieves all asset balances associated with the given GAID,
9680 /// including confirmed balances and any lost outputs.
9681 ///
9682 /// # Arguments
9683 /// * `gaid` - The GAID to query balance for
9684 ///
9685 /// # Returns
9686 /// Returns a `Balance` struct containing confirmed balances and lost outputs
9687 ///
9688 /// # Errors
9689 /// Returns an error if:
9690 /// - The GAID is invalid
9691 /// - Network or authentication errors occur
9692 /// - The response cannot be parsed
9693 ///
9694 /// # Examples
9695 /// ```no_run
9696 /// # use amp_rs::ApiClient;
9697 /// # #[tokio::main]
9698 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9699 /// let client = ApiClient::new().await?;
9700 ///
9701 /// let gaid = "GAbYScu6jkWUND2jo3L4KJxyvo55d";
9702 /// let balance = client.get_gaid_balance(gaid).await?;
9703 ///
9704 /// println!("GAID {} has {} balance entries", gaid, balance.len());
9705 /// for entry in balance {
9706 /// println!("Asset {}: {} units", entry.asset_id, entry.balance);
9707 /// }
9708 /// # Ok(())
9709 /// # }
9710 /// ```
9711 pub async fn get_gaid_balance(&self, gaid: &str) -> Result<Balance, Error> {
9712 self.request_json(Method::GET, &["gaids", gaid, "balance"], None::<&()>)
9713 .await
9714 }
9715
9716 /// Retrieves the specific asset balance for a GAID
9717 ///
9718 /// # Arguments
9719 /// * `gaid` - The GAID to query
9720 /// * `asset_uuid` - The UUID of the asset to query
9721 ///
9722 /// # Returns
9723 /// Returns the specific asset balance information
9724 ///
9725 /// # Errors
9726 /// Returns an error if:
9727 /// - The GAID is invalid
9728 /// - The asset UUID is invalid
9729 /// - Network or authentication errors occur
9730 /// - The response cannot be parsed
9731 pub async fn get_gaid_asset_balance(
9732 &self,
9733 gaid: &str,
9734 asset_uuid: &str,
9735 ) -> Result<Ownership, Error> {
9736 // Try to get the response as a GaidBalanceEntry first, then convert to Ownership
9737 let balance_entry: GaidBalanceEntry = self
9738 .request_json(
9739 Method::GET,
9740 &["gaids", gaid, "balance", asset_uuid],
9741 None::<&()>,
9742 )
9743 .await?;
9744
9745 // Convert GaidBalanceEntry to Ownership format
9746 Ok(Ownership {
9747 owner: gaid.to_string(),
9748 amount: balance_entry.balance,
9749 gaid: Some(gaid.to_string()),
9750 })
9751 }
9752
9753 /// Gets a list of all categories.
9754 ///
9755 /// # Returns
9756 /// Returns a vector of `CategoryResponse` objects
9757 ///
9758 /// # Errors
9759 /// Returns an error if:
9760 /// - Authentication fails
9761 /// - The HTTP request fails
9762 /// - The response cannot be parsed
9763 ///
9764 /// # Examples
9765 /// ```no_run
9766 /// # use amp_rs::ApiClient;
9767 /// # #[tokio::main]
9768 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9769 /// let client = ApiClient::new().await?;
9770 ///
9771 /// let categories = client.get_categories().await?;
9772 /// for category in categories {
9773 /// println!("Category: {} (ID: {})", category.name, category.id);
9774 /// if let Some(desc) = category.description {
9775 /// println!(" Description: {}", desc);
9776 /// }
9777 /// }
9778 /// # Ok(())
9779 /// # }
9780 /// ```
9781 pub async fn get_categories(&self) -> Result<Vec<CategoryResponse>, Error> {
9782 self.request_json(Method::GET, &["categories"], None::<&()>)
9783 .await
9784 }
9785
9786 /// Creates a new category for organizing users and assets.
9787 ///
9788 /// This method creates a new category that can be used to group registered users and assets
9789 /// for organizational purposes. Categories help manage permissions and provide logical
9790 /// groupings for assets and users.
9791 ///
9792 /// # Arguments
9793 /// * `new_category` - A `CategoryAdd` struct containing the category information to create
9794 ///
9795 /// # Returns
9796 /// Returns a `CategoryResponse` containing the created category information including
9797 /// the assigned category ID.
9798 ///
9799 /// # Errors
9800 /// Returns an error if:
9801 /// - Authentication fails or insufficient permissions
9802 /// - The category data is invalid (e.g., missing name, invalid characters)
9803 /// - A category with the same name already exists
9804 /// - The HTTP request fails
9805 /// - The server returns an error status
9806 /// - The response cannot be parsed
9807 ///
9808 /// # Examples
9809 /// ```no_run
9810 /// # use amp_rs::{ApiClient, model::CategoryAdd};
9811 /// # #[tokio::main]
9812 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9813 /// let client = ApiClient::new().await?;
9814 ///
9815 /// let new_category = CategoryAdd {
9816 /// name: "Premium Users".to_string(),
9817 /// description: Some("High-value users with special privileges".to_string()),
9818 /// };
9819 ///
9820 /// let created_category = client.add_category(&new_category).await?;
9821 /// println!("Created category: {} with ID {}", created_category.name, created_category.id);
9822 /// # Ok(())
9823 /// # }
9824 /// ```
9825 ///
9826 /// # Related Methods
9827 /// - [`get_categories`](Self::get_categories) - List all categories
9828 /// - [`edit_category`](Self::edit_category) - Update category information
9829 /// - [`delete_category`](Self::delete_category) - Remove a category
9830 /// - [`add_registered_user_to_category`](Self::add_registered_user_to_category) - Add users to category
9831 pub async fn add_category(
9832 &self,
9833 new_category: &CategoryAdd,
9834 ) -> Result<CategoryResponse, Error> {
9835 self.request_json(Method::POST, &["categories", "add"], Some(new_category))
9836 .await
9837 }
9838
9839 /// Gets a specific category by ID.
9840 ///
9841 /// This method retrieves detailed information about a specific category, including
9842 /// its name, description, and associated users and assets.
9843 ///
9844 /// # Arguments
9845 /// * `category_id` - The ID of the category to retrieve
9846 ///
9847 /// # Returns
9848 /// Returns a `CategoryResponse` containing the category information including:
9849 /// - Category ID, name, and description
9850 /// - List of associated registered users
9851 /// - List of associated assets
9852 /// - Creation and modification timestamps
9853 ///
9854 /// # Errors
9855 /// Returns an error if:
9856 /// - Authentication fails or insufficient permissions
9857 /// - The category ID is invalid or does not exist
9858 /// - The HTTP request fails
9859 /// - The server returns an error status
9860 /// - The response cannot be parsed
9861 ///
9862 /// # Examples
9863 /// ```no_run
9864 /// # use amp_rs::ApiClient;
9865 /// # #[tokio::main]
9866 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9867 /// let client = ApiClient::new().await?;
9868 ///
9869 /// let category_id = 1;
9870 /// let category = client.get_category(category_id).await?;
9871 ///
9872 /// println!("Category: {} (ID: {})", category.name, category.id);
9873 /// if let Some(desc) = category.description {
9874 /// println!("Description: {}", desc);
9875 /// }
9876 /// println!("Users: {}, Assets: {}", category.registered_users.len(), category.assets.len());
9877 /// # Ok(())
9878 /// # }
9879 /// ```
9880 ///
9881 /// # Related Methods
9882 /// - [`get_categories`](Self::get_categories) - List all categories
9883 /// - [`add_category`](Self::add_category) - Create a new category
9884 /// - [`edit_category`](Self::edit_category) - Update category information
9885 /// - [`delete_category`](Self::delete_category) - Remove a category
9886 pub async fn get_category(&self, category_id: i64) -> Result<CategoryResponse, Error> {
9887 self.request_json(
9888 Method::GET,
9889 &["categories", &category_id.to_string()],
9890 None::<&()>,
9891 )
9892 .await
9893 }
9894
9895 /// Updates category information.
9896 ///
9897 /// This method allows you to modify the information of an existing category.
9898 /// Only the fields provided in the edit data will be updated; other fields remain unchanged.
9899 ///
9900 /// # Arguments
9901 /// * `category_id` - The ID of the category to update
9902 /// * `edit_category` - A `CategoryEdit` struct containing the fields to update
9903 ///
9904 /// # Returns
9905 /// Returns a `CategoryResponse` containing the updated category information.
9906 ///
9907 /// # Errors
9908 /// Returns an error if:
9909 /// - Authentication fails or insufficient permissions
9910 /// - The category ID is invalid or does not exist
9911 /// - The edit data contains invalid values (e.g., empty name, invalid characters)
9912 /// - A category with the new name already exists (if name is being changed)
9913 /// - The HTTP request fails
9914 /// - The server returns an error status
9915 /// - The response cannot be parsed
9916 ///
9917 /// # Examples
9918 /// ```no_run
9919 /// # use amp_rs::{ApiClient, model::CategoryEdit};
9920 /// # #[tokio::main]
9921 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9922 /// let client = ApiClient::new().await?;
9923 ///
9924 /// let category_id = 1;
9925 /// let edit_data = CategoryEdit {
9926 /// name: Some("VIP Users".to_string()),
9927 /// description: Some("Very important users with premium access".to_string()),
9928 /// };
9929 ///
9930 /// let updated_category = client.edit_category(category_id, &edit_data).await?;
9931 /// println!("Updated category: {}", updated_category.name);
9932 /// # Ok(())
9933 /// # }
9934 /// ```
9935 ///
9936 /// # Related Methods
9937 /// - [`get_category`](Self::get_category) - Get current category information
9938 /// - [`add_category`](Self::add_category) - Create a new category
9939 /// - [`delete_category`](Self::delete_category) - Remove a category
9940 pub async fn edit_category(
9941 &self,
9942 category_id: i64,
9943 edit_category: &CategoryEdit,
9944 ) -> Result<CategoryResponse, Error> {
9945 self.request_json(
9946 Method::PUT,
9947 &["categories", &category_id.to_string(), "edit"],
9948 Some(edit_category),
9949 )
9950 .await
9951 }
9952
9953 /// Removes a category from the system.
9954 ///
9955 /// This method permanently deletes a category. All users and assets associated with the
9956 /// category will be disassociated, but the users and assets themselves are not deleted.
9957 /// This operation cannot be undone.
9958 ///
9959 /// # Arguments
9960 /// * `category_id` - The ID of the category to delete
9961 ///
9962 /// # Returns
9963 /// Returns `Ok(())` on successful deletion.
9964 ///
9965 /// # Errors
9966 /// Returns an error if:
9967 /// - Authentication fails or insufficient permissions
9968 /// - The category ID is invalid or does not exist
9969 /// - The category is still in use and cannot be deleted (depending on system configuration)
9970 /// - The HTTP request fails
9971 /// - The server returns an error status
9972 ///
9973 /// # Examples
9974 /// ```no_run
9975 /// # use amp_rs::ApiClient;
9976 /// # #[tokio::main]
9977 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
9978 /// let client = ApiClient::new().await?;
9979 ///
9980 /// let category_id = 1;
9981 /// client.delete_category(category_id).await?;
9982 /// println!("Successfully deleted category with ID {}", category_id);
9983 /// # Ok(())
9984 /// # }
9985 /// ```
9986 ///
9987 /// # Related Methods
9988 /// - [`get_category`](Self::get_category) - Get category information before deletion
9989 /// - [`add_category`](Self::add_category) - Create a new category
9990 /// - [`remove_registered_user_from_category`](Self::remove_registered_user_from_category) - Remove users first
9991 /// - [`remove_asset_from_category`](Self::remove_asset_from_category) - Remove assets first
9992 pub async fn delete_category(&self, category_id: i64) -> Result<(), Error> {
9993 self.request_empty(
9994 Method::DELETE,
9995 &["categories", &category_id.to_string(), "delete"],
9996 None::<&()>,
9997 )
9998 .await
9999 }
10000
10001 /// Associates a registered user with a category.
10002 ///
10003 /// This method adds a registered user to a category, allowing for organized grouping
10004 /// of users. Users can belong to multiple categories, and categories can contain
10005 /// multiple users.
10006 ///
10007 /// # Arguments
10008 /// * `category_id` - The ID of the category to add the user to
10009 /// * `user_id` - The ID of the registered user to add to the category
10010 ///
10011 /// # Returns
10012 /// Returns a `CategoryResponse` containing the updated category information including
10013 /// the newly added user.
10014 ///
10015 /// # Errors
10016 /// Returns an error if:
10017 /// - Authentication fails or insufficient permissions
10018 /// - The category ID is invalid or does not exist
10019 /// - The user ID is invalid or does not exist
10020 /// - The user is already associated with the category
10021 /// - The HTTP request fails
10022 /// - The server returns an error status
10023 /// - The response cannot be parsed
10024 ///
10025 /// # Examples
10026 /// ```no_run
10027 /// # use amp_rs::ApiClient;
10028 /// # #[tokio::main]
10029 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10030 /// let client = ApiClient::new().await?;
10031 ///
10032 /// let category_id = 1;
10033 /// let user_id = 123;
10034 ///
10035 /// let updated_category = client.add_registered_user_to_category(category_id, user_id).await?;
10036 /// println!("Added user {} to category '{}'", user_id, updated_category.name);
10037 /// println!("Category now has {} users", updated_category.registered_users.len());
10038 /// # Ok(())
10039 /// # }
10040 /// ```
10041 ///
10042 /// # Related Methods
10043 /// - [`remove_registered_user_from_category`](Self::remove_registered_user_from_category) - Remove user from category
10044 /// - [`get_category`](Self::get_category) - Get category information including users
10045 /// - [`get_registered_user`](Self::get_registered_user) - Get user information
10046 pub async fn add_registered_user_to_category(
10047 &self,
10048 category_id: i64,
10049 user_id: i64,
10050 ) -> Result<CategoryResponse, Error> {
10051 self.request_json(
10052 Method::PUT,
10053 &[
10054 "categories",
10055 &category_id.to_string(),
10056 "registered_users",
10057 &user_id.to_string(),
10058 "add",
10059 ],
10060 None::<&()>,
10061 )
10062 .await
10063 }
10064
10065 /// Removes a registered user from a category.
10066 ///
10067 /// This method disassociates a registered user from a category. The user remains in the
10068 /// system but is no longer part of the specified category. This does not affect the user's
10069 /// association with other categories.
10070 ///
10071 /// # Arguments
10072 /// * `category_id` - The ID of the category to remove the user from
10073 /// * `user_id` - The ID of the registered user to remove from the category
10074 ///
10075 /// # Returns
10076 /// Returns a `CategoryResponse` containing the updated category information without
10077 /// the removed user.
10078 ///
10079 /// # Errors
10080 /// Returns an error if:
10081 /// - Authentication fails or insufficient permissions
10082 /// - The category ID is invalid or does not exist
10083 /// - The user ID is invalid or does not exist
10084 /// - The user is not currently associated with the category
10085 /// - The HTTP request fails
10086 /// - The server returns an error status
10087 /// - The response cannot be parsed
10088 ///
10089 /// # Examples
10090 /// ```no_run
10091 /// # use amp_rs::ApiClient;
10092 /// # #[tokio::main]
10093 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10094 /// let client = ApiClient::new().await?;
10095 ///
10096 /// let category_id = 1;
10097 /// let user_id = 123;
10098 ///
10099 /// let updated_category = client.remove_registered_user_from_category(category_id, user_id).await?;
10100 /// println!("Removed user {} from category '{}'", user_id, updated_category.name);
10101 /// println!("Category now has {} users", updated_category.registered_users.len());
10102 /// # Ok(())
10103 /// # }
10104 /// ```
10105 ///
10106 /// # Related Methods
10107 /// - [`add_registered_user_to_category`](Self::add_registered_user_to_category) - Add user to category
10108 /// - [`get_category`](Self::get_category) - Get category information including users
10109 /// - [`get_registered_user`](Self::get_registered_user) - Get user information
10110 pub async fn remove_registered_user_from_category(
10111 &self,
10112 category_id: i64,
10113 user_id: i64,
10114 ) -> Result<CategoryResponse, Error> {
10115 self.request_json(
10116 Method::PUT,
10117 &[
10118 "categories",
10119 &category_id.to_string(),
10120 "registered_users",
10121 &user_id.to_string(),
10122 "remove",
10123 ],
10124 None::<&()>,
10125 )
10126 .await
10127 }
10128
10129 /// Associates an asset with a category.
10130 ///
10131 /// This method adds an asset to a category, allowing for organized grouping of assets.
10132 /// Assets can belong to multiple categories, and categories can contain multiple assets.
10133 /// This helps with asset management and permission organization.
10134 ///
10135 /// # Arguments
10136 /// * `category_id` - The ID of the category to add the asset to
10137 /// * `asset_uuid` - The UUID of the asset to add to the category
10138 ///
10139 /// # Returns
10140 /// Returns a `CategoryResponse` containing the updated category information including
10141 /// the newly added asset.
10142 ///
10143 /// # Errors
10144 /// Returns an error if:
10145 /// - Authentication fails or insufficient permissions
10146 /// - The category ID is invalid or does not exist
10147 /// - The asset UUID is invalid or does not exist
10148 /// - The asset is already associated with the category
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;
10156 /// # #[tokio::main]
10157 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10158 /// let client = ApiClient::new().await?;
10159 ///
10160 /// let category_id = 1;
10161 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10162 ///
10163 /// let updated_category = client.add_asset_to_category(category_id, asset_uuid).await?;
10164 /// println!("Added asset {} to category '{}'", asset_uuid, updated_category.name);
10165 /// println!("Category now has {} assets", updated_category.assets.len());
10166 /// # Ok(())
10167 /// # }
10168 /// ```
10169 ///
10170 /// # Related Methods
10171 /// - [`remove_asset_from_category`](Self::remove_asset_from_category) - Remove asset from category
10172 /// - [`get_category`](Self::get_category) - Get category information including assets
10173 /// - [`get_asset`](Self::get_asset) - Get asset information
10174 pub async fn add_asset_to_category(
10175 &self,
10176 category_id: i64,
10177 asset_uuid: &str,
10178 ) -> Result<CategoryResponse, Error> {
10179 self.request_json(
10180 Method::PUT,
10181 &[
10182 "categories",
10183 &category_id.to_string(),
10184 "assets",
10185 asset_uuid,
10186 "add",
10187 ],
10188 None::<&()>,
10189 )
10190 .await
10191 }
10192
10193 /// Removes an asset from a category.
10194 ///
10195 /// This method disassociates an asset from a category. The asset remains in the system
10196 /// but is no longer part of the specified category. This does not affect the asset's
10197 /// association with other categories.
10198 ///
10199 /// # Arguments
10200 /// * `category_id` - The ID of the category to remove the asset from
10201 /// * `asset_uuid` - The UUID of the asset to remove from the category
10202 ///
10203 /// # Returns
10204 /// Returns a `CategoryResponse` containing the updated category information without
10205 /// the removed asset.
10206 ///
10207 /// # Errors
10208 /// Returns an error if:
10209 /// - Authentication fails or insufficient permissions
10210 /// - The category ID is invalid or does not exist
10211 /// - The asset UUID is invalid or does not exist
10212 /// - The asset is not currently associated with the category
10213 /// - The HTTP request fails
10214 /// - The server returns an error status
10215 /// - The response cannot be parsed
10216 ///
10217 /// # Examples
10218 /// ```no_run
10219 /// # use amp_rs::ApiClient;
10220 /// # #[tokio::main]
10221 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10222 /// let client = ApiClient::new().await?;
10223 ///
10224 /// let category_id = 1;
10225 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10226 ///
10227 /// let updated_category = client.remove_asset_from_category(category_id, asset_uuid).await?;
10228 /// println!("Removed asset {} from category '{}'", asset_uuid, updated_category.name);
10229 /// println!("Category now has {} assets", updated_category.assets.len());
10230 /// # Ok(())
10231 /// # }
10232 /// ```
10233 ///
10234 /// # Related Methods
10235 /// - [`add_asset_to_category`](Self::add_asset_to_category) - Add asset to category
10236 /// - [`get_category`](Self::get_category) - Get category information including assets
10237 /// - [`get_asset`](Self::get_asset) - Get asset information
10238 pub async fn remove_asset_from_category(
10239 &self,
10240 category_id: i64,
10241 asset_uuid: &str,
10242 ) -> Result<CategoryResponse, Error> {
10243 self.request_json(
10244 Method::PUT,
10245 &[
10246 "categories",
10247 &category_id.to_string(),
10248 "assets",
10249 asset_uuid,
10250 "remove",
10251 ],
10252 None::<&()>,
10253 )
10254 .await
10255 }
10256
10257 /// Validates a GAID (Green Address ID).
10258 ///
10259 /// # Arguments
10260 /// * `gaid` - The GAID string to validate
10261 ///
10262 /// # Returns
10263 /// Returns a `ValidateGaidResponse` indicating whether the GAID is valid
10264 ///
10265 /// # Errors
10266 /// Returns an error if:
10267 /// - Authentication fails
10268 /// - The HTTP request fails
10269 /// - The response cannot be parsed
10270 ///
10271 /// # Examples
10272 /// ```no_run
10273 /// # use amp_rs::ApiClient;
10274 /// # #[tokio::main]
10275 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10276 /// let client = ApiClient::new().await?;
10277 ///
10278 /// let gaid = "GAbYScu6jkWUND2jo3L4KJxyvo55d";
10279 /// let validation = client.validate_gaid(gaid).await?;
10280 ///
10281 /// if validation.is_valid {
10282 /// println!("GAID {} is valid", gaid);
10283 /// } else {
10284 /// println!("GAID {} is invalid: {:?}", gaid, validation.error);
10285 /// }
10286 /// # Ok(())
10287 /// # }
10288 /// ```
10289 pub async fn validate_gaid(
10290 &self,
10291 gaid: &str,
10292 ) -> Result<crate::model::ValidateGaidResponse, Error> {
10293 self.request_json(Method::GET, &["gaids", gaid, "validate"], None::<&()>)
10294 .await
10295 }
10296
10297 /// Gets the address associated with a GAID.
10298 ///
10299 /// # Arguments
10300 /// * `gaid` - The GAID to get the address for
10301 ///
10302 /// # Returns
10303 /// Returns an `AddressGaidResponse` containing the address
10304 ///
10305 /// # Errors
10306 /// Returns an error if:
10307 /// - The GAID is invalid
10308 /// - Authentication fails
10309 /// - The HTTP request fails
10310 /// - The response cannot be parsed
10311 ///
10312 /// # Examples
10313 /// ```no_run
10314 /// # use amp_rs::ApiClient;
10315 /// # #[tokio::main]
10316 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10317 /// let client = ApiClient::new().await?;
10318 ///
10319 /// let gaid = "GAbYScu6jkWUND2jo3L4KJxyvo55d";
10320 /// let address_response = client.get_gaid_address(gaid).await?;
10321 ///
10322 /// println!("Address for GAID {}: {}", gaid, address_response.address);
10323 /// # Ok(())
10324 /// # }
10325 /// ```
10326 pub async fn get_gaid_address(
10327 &self,
10328 gaid: &str,
10329 ) -> Result<crate::model::AddressGaidResponse, Error> {
10330 self.request_json(Method::GET, &["gaids", gaid, "address"], None::<&()>)
10331 .await
10332 }
10333
10334 /// Gets a list of all managers.
10335 ///
10336 /// # Returns
10337 /// Returns a vector of `Manager` objects
10338 ///
10339 /// # Errors
10340 /// Returns an error if:
10341 /// - Authentication fails
10342 /// - The HTTP request fails
10343 /// - The response cannot be parsed
10344 ///
10345 /// # Examples
10346 /// ```no_run
10347 /// # use amp_rs::ApiClient;
10348 /// # #[tokio::main]
10349 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10350 /// let client = ApiClient::new().await?;
10351 ///
10352 /// let managers = client.get_managers().await?;
10353 /// for manager in managers {
10354 /// println!("Manager: {} (ID: {})", manager.username, manager.id);
10355 /// }
10356 /// # Ok(())
10357 /// # }
10358 /// ```
10359 pub async fn get_managers(&self) -> Result<Vec<crate::model::Manager>, Error> {
10360 self.request_json(Method::GET, &["managers"], None::<&()>)
10361 .await
10362 }
10363
10364 /// Creates a new manager.
10365 ///
10366 /// # Arguments
10367 /// * `new_manager` - The manager creation request containing username and password
10368 ///
10369 /// # Returns
10370 /// Returns the created `Manager` object
10371 ///
10372 /// # Errors
10373 /// Returns an error if:
10374 /// - Authentication fails
10375 /// - The HTTP request fails
10376 /// - The manager creation request is invalid
10377 /// - The response cannot be parsed
10378 ///
10379 /// # Examples
10380 /// ```no_run
10381 /// # use amp_rs::{ApiClient, model::ManagerCreate};
10382 /// # #[tokio::main]
10383 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10384 /// let client = ApiClient::new().await?;
10385 ///
10386 /// let new_manager = ManagerCreate {
10387 /// username: "new_manager".to_string(),
10388 /// password: "secure_password".to_string(),
10389 /// };
10390 ///
10391 /// let manager = client.create_manager(&new_manager).await?;
10392 /// println!("Created manager: {} (ID: {})", manager.username, manager.id);
10393 /// # Ok(())
10394 /// # }
10395 /// ```
10396 pub async fn create_manager(
10397 &self,
10398 new_manager: &crate::model::ManagerCreate,
10399 ) -> Result<crate::model::Manager, Error> {
10400 self.request_json(Method::POST, &["managers", "create"], Some(new_manager))
10401 .await
10402 }
10403
10404 /// Gets all assignments for a specific asset.
10405 ///
10406 /// # Arguments
10407 /// * `asset_uuid` - The UUID of the asset to get assignments for
10408 ///
10409 /// # Returns
10410 /// Returns a vector of `Assignment` objects
10411 ///
10412 /// # Errors
10413 /// Returns an error if:
10414 /// - Authentication fails
10415 /// - The HTTP request fails
10416 /// - The asset UUID is invalid
10417 /// - The response cannot be parsed
10418 ///
10419 /// # Examples
10420 /// ```no_run
10421 /// # use amp_rs::ApiClient;
10422 /// # #[tokio::main]
10423 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10424 /// let client = ApiClient::new().await?;
10425 ///
10426 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10427 /// let assignments = client.get_asset_assignments(asset_uuid).await?;
10428 ///
10429 /// for assignment in assignments {
10430 /// println!("Assignment ID: {}, Amount: {}", assignment.id, assignment.amount);
10431 /// }
10432 /// # Ok(())
10433 /// # }
10434 /// ```
10435 pub async fn get_asset_assignments(&self, asset_uuid: &str) -> Result<Vec<Assignment>, Error> {
10436 self.request_json(
10437 Method::GET,
10438 &["assets", asset_uuid, "assignments"],
10439 None::<&()>,
10440 )
10441 .await
10442 }
10443
10444 /// Creates multiple asset assignments in batch.
10445 ///
10446 /// This method creates multiple asset assignments for the specified asset. Each assignment
10447 /// allocates a specific amount of the asset to a registered user. The assignments are
10448 /// created individually due to API limitations, but this method handles the batch processing
10449 /// automatically.
10450 ///
10451 /// # Arguments
10452 /// * `asset_uuid` - The UUID of the asset to create assignments for
10453 /// * `requests` - A slice of `CreateAssetAssignmentRequest` structs containing assignment details
10454 ///
10455 /// # Returns
10456 /// Returns a vector of `Assignment` structs representing the created assignments with their
10457 /// assigned IDs and status information.
10458 ///
10459 /// # Errors
10460 /// Returns an error if:
10461 /// - Authentication fails or insufficient permissions
10462 /// - The asset UUID is invalid or does not exist
10463 /// - Any assignment request contains invalid data (e.g., invalid user ID, negative amount)
10464 /// - Insufficient asset balance for the total requested assignments
10465 /// - Any individual assignment creation fails
10466 /// - The HTTP request fails
10467 /// - The server returns an error status
10468 /// - The response cannot be parsed
10469 ///
10470 /// # Examples
10471 /// ```no_run
10472 /// # use amp_rs::{ApiClient, model::CreateAssetAssignmentRequest};
10473 /// # #[tokio::main]
10474 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10475 /// let client = ApiClient::new().await?;
10476 ///
10477 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10478 /// let requests = vec![
10479 /// CreateAssetAssignmentRequest {
10480 /// registered_user: 123,
10481 /// amount: 1000,
10482 /// vesting_timestamp: None,
10483 /// ready_for_distribution: false,
10484 /// },
10485 /// CreateAssetAssignmentRequest {
10486 /// registered_user: 456,
10487 /// amount: 500,
10488 /// vesting_timestamp: None,
10489 /// ready_for_distribution: true,
10490 /// },
10491 /// ];
10492 ///
10493 /// let assignments = client.create_asset_assignments(asset_uuid, &requests).await?;
10494 /// println!("Created {} assignments", assignments.len());
10495 /// for assignment in assignments {
10496 /// println!("Assignment {}: {} units to user {}",
10497 /// assignment.id, assignment.amount, assignment.registered_user);
10498 /// }
10499 /// # Ok(())
10500 /// # }
10501 /// ```
10502 ///
10503 /// # Related Methods
10504 /// - [`get_asset_assignments`](Self::get_asset_assignments) - List all assignments for an asset
10505 /// - [`delete_asset_assignment`](Self::delete_asset_assignment) - Remove an assignment
10506 /// - [`edit_asset_assignment`](Self::edit_asset_assignment) - Update assignment details
10507 /// - [`set_assignment_ready_for_distribution`](Self::set_assignment_ready_for_distribution) - Mark for distribution
10508 pub async fn create_asset_assignments(
10509 &self,
10510 asset_uuid: &str,
10511 requests: &[CreateAssetAssignmentRequest],
10512 ) -> Result<Vec<Assignment>, Error> {
10513 use crate::model::CreateAssetAssignmentRequestWrapper;
10514
10515 // The API only supports maximum length 1 per request, so we need to break
10516 // multiple assignments into separate CreateAssetAssignmentRequestWrapper instances
10517 let mut all_assignments = Vec::new();
10518
10519 for request in requests {
10520 let wrapper = CreateAssetAssignmentRequestWrapper {
10521 assignments: vec![request.clone()],
10522 };
10523
10524 let assignments: Vec<Assignment> = self
10525 .request_json(
10526 Method::POST,
10527 &["assets", asset_uuid, "assignments", "create"],
10528 Some(&wrapper),
10529 )
10530 .await?;
10531
10532 all_assignments.extend(assignments);
10533 }
10534
10535 Ok(all_assignments)
10536 }
10537
10538 /// Gets a specific asset assignment by asset UUID and assignment ID.
10539 ///
10540 /// This method sends a GET request to retrieve detailed information about a specific asset
10541 /// assignment. Asset assignments represent the allocation of assets to users or entities,
10542 /// including information such as the assigned amount, recipient details, and assignment status.
10543 ///
10544 /// # Arguments
10545 /// * `asset_uuid` - The UUID of the asset for which to retrieve the assignment
10546 /// * `assignment_id` - The ID of the specific assignment to retrieve
10547 ///
10548 /// # Returns
10549 /// Returns an `Assignment` struct containing the assignment details including:
10550 /// - Assignment ID and amount
10551 /// - Recipient information
10552 /// - Assignment status and metadata
10553 /// - Creation and modification timestamps
10554 ///
10555 /// # Errors
10556 /// Returns an error if:
10557 /// - Authentication fails
10558 /// - The HTTP request fails
10559 /// - The server returns an error status
10560 /// - The asset UUID is invalid or does not exist
10561 /// - The assignment ID is invalid or does not exist
10562 /// - The assignment is not accessible to the current user
10563 /// - The response cannot be parsed as a valid Assignment
10564 ///
10565 /// # Example
10566 /// ```no_run
10567 /// # use amp_rs::ApiClient;
10568 /// # #[tokio::main]
10569 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
10570 /// let client = ApiClient::new().await?;
10571 ///
10572 /// // Retrieve assignment with ID "123" for asset "550e8400-e29b-41d4-a716-446655440000"
10573 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10574 /// let assignment_id = "123";
10575 ///
10576 /// let assignment = client.get_asset_assignment(asset_uuid, assignment_id).await?;
10577 ///
10578 /// println!("Assignment ID: {}", assignment.id);
10579 /// println!("Assigned amount: {}", assignment.amount);
10580 /// println!("Registered user: {}", assignment.registered_user);
10581 /// # Ok(())
10582 /// # }
10583 /// ```
10584 pub async fn get_asset_assignment(
10585 &self,
10586 asset_uuid: &str,
10587 assignment_id: &str,
10588 ) -> Result<Assignment, Error> {
10589 self.request_json(
10590 Method::GET,
10591 &["assets", asset_uuid, "assignments", assignment_id],
10592 None::<&()>,
10593 )
10594 .await
10595 }
10596
10597 /// Creates a distribution for an asset with the specified assignments.
10598 ///
10599 /// This method initiates the distribution creation process by sending assignment details
10600 /// to the AMP API. The API will return a distribution UUID and address mappings that
10601 /// can be used for subsequent transaction creation and confirmation steps.
10602 ///
10603 /// # Arguments
10604 /// * `asset_uuid` - The UUID of the asset to distribute
10605 /// * `assignments` - A vector of `AssetDistributionAssignment` structs containing user IDs, addresses, and amounts
10606 ///
10607 /// # Returns
10608 /// Returns a `DistributionResponse` containing:
10609 /// - `distribution_uuid` - Unique identifier for the created distribution
10610 /// - `map_address_amount` - Mapping of addresses to amounts to be distributed
10611 /// - `map_address_asset` - Mapping of addresses to asset IDs
10612 /// - `asset_id` - The asset ID for the distribution
10613 ///
10614 /// # Errors
10615 /// Returns an `AmpError` if:
10616 /// - Authentication fails or insufficient permissions
10617 /// - The asset UUID is invalid or does not exist
10618 /// - Assignment data is invalid (e.g., invalid user IDs, negative amounts, invalid addresses)
10619 /// - Insufficient asset balance for the requested distribution
10620 /// - The HTTP request fails
10621 /// - The server returns an error status
10622 /// - The response cannot be parsed
10623 ///
10624 /// # Examples
10625 /// ```no_run
10626 /// # use amp_rs::{ApiClient, model::AssetDistributionAssignment, AmpError};
10627 /// # #[tokio::main]
10628 /// # async fn main() -> Result<(), AmpError> {
10629 /// let client = ApiClient::new().await.map_err(AmpError::from)?;
10630 ///
10631 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10632 /// let assignments = vec![
10633 /// AssetDistributionAssignment {
10634 /// user_id: "user123".to_string(),
10635 /// address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
10636 /// amount: 100.0,
10637 /// },
10638 /// AssetDistributionAssignment {
10639 /// user_id: "user456".to_string(),
10640 /// address: "lq1qq3xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
10641 /// amount: 50.0,
10642 /// },
10643 /// ];
10644 ///
10645 /// let distribution_response = client.create_distribution(asset_uuid, assignments).await?;
10646 /// println!("Created distribution: {}", distribution_response.distribution_uuid);
10647 /// println!("Asset ID: {}", distribution_response.asset_id);
10648 /// # Ok(())
10649 /// # }
10650 /// ```
10651 ///
10652 /// # Related Methods
10653 /// - [`get_asset_assignments`](Self::get_asset_assignments) - List assignments for an asset
10654 /// - [`create_asset_assignments`](Self::create_asset_assignments) - Create new assignments
10655 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
10656 pub async fn create_distribution(
10657 &self,
10658 asset_uuid: &str,
10659 assignments: Vec<crate::model::AssetDistributionAssignment>,
10660 ) -> Result<crate::model::DistributionResponse, AmpError> {
10661 use crate::model::{CreateDistributionRequest, DistributionAssignmentRequest};
10662
10663 let create_span = tracing::debug_span!(
10664 "create_distribution",
10665 asset_uuid = %asset_uuid,
10666 assignment_count = assignments.len()
10667 );
10668 let _enter = create_span.enter();
10669
10670 tracing::debug!(
10671 "Creating distribution for asset {} with {} assignments",
10672 asset_uuid,
10673 assignments.len()
10674 );
10675
10676 // Validate inputs
10677 if asset_uuid.is_empty() {
10678 tracing::error!("Distribution creation failed: empty asset UUID");
10679 return Err(AmpError::validation("Asset UUID cannot be empty"));
10680 }
10681
10682 if assignments.is_empty() {
10683 tracing::error!("Distribution creation failed: empty assignments");
10684 return Err(AmpError::validation("Assignments cannot be empty"));
10685 }
10686
10687 // Convert AssetDistributionAssignment to DistributionAssignmentRequest
10688 // The API expects user_uuid field, but our input uses user_id
10689 tracing::trace!("Converting {} assignments to API format", assignments.len());
10690 let mut total_amount = 0.0;
10691 let api_assignments: Vec<DistributionAssignmentRequest> = assignments
10692 .into_iter()
10693 .enumerate()
10694 .map(
10695 #[allow(clippy::cognitive_complexity)]
10696 |(index, assignment)| {
10697 tracing::trace!(
10698 "Converting assignment {}: user_id={}, address={}, amount={}",
10699 index,
10700 assignment.user_id,
10701 assignment.address,
10702 assignment.amount
10703 );
10704
10705 // Validate assignment data
10706 if assignment.user_id.is_empty() {
10707 tracing::error!("Assignment {} has empty user_id", index);
10708 return Err(AmpError::validation(format!(
10709 "Assignment {index} has empty user_id"
10710 )));
10711 }
10712 if assignment.address.is_empty() {
10713 tracing::error!("Assignment {} has empty address", index);
10714 return Err(AmpError::validation(format!(
10715 "Assignment {index} has empty address"
10716 )));
10717 }
10718 if assignment.amount <= 0.0 {
10719 tracing::error!(
10720 "Assignment {} has non-positive amount: {}",
10721 index,
10722 assignment.amount
10723 );
10724 return Err(AmpError::validation(format!(
10725 "Assignment {} has non-positive amount: {}",
10726 index, assignment.amount
10727 )));
10728 }
10729
10730 total_amount += assignment.amount;
10731
10732 Ok(DistributionAssignmentRequest {
10733 user_uuid: assignment.user_id, // Map user_id to user_uuid for API
10734 amount: assignment.amount,
10735 address: assignment.address,
10736 })
10737 },
10738 )
10739 .collect::<Result<Vec<_>, AmpError>>()?;
10740
10741 tracing::debug!(
10742 "Converted {} assignments successfully, total amount: {}",
10743 api_assignments.len(),
10744 total_amount
10745 );
10746
10747 let request = CreateDistributionRequest {
10748 assignments: api_assignments,
10749 };
10750
10751 tracing::debug!("Sending distribution creation request to AMP API");
10752 let api_call_start = std::time::Instant::now();
10753
10754 // Make the API call
10755 let response: crate::model::DistributionResponse = self
10756 .request_json(
10757 Method::GET,
10758 &["assets", asset_uuid, "distributions", "create"],
10759 Some(&request),
10760 )
10761 .await
10762 .map_err(
10763 #[allow(clippy::cognitive_complexity)]
10764 |e| {
10765 let api_call_duration = api_call_start.elapsed();
10766 let error_msg =
10767 format!("Failed to create distribution after {api_call_duration:?}: {e}");
10768 tracing::error!("{}", error_msg);
10769
10770 // Check for specific API error patterns
10771 let error_str = e.to_string();
10772 if error_str.contains("404") || error_str.contains("not found") {
10773 tracing::error!(
10774 "Asset {} not found - verify asset UUID is correct",
10775 asset_uuid
10776 );
10777 } else if error_str.contains("400") || error_str.contains("bad request") {
10778 tracing::error!("Bad request - check assignment data format and values");
10779 } else if error_str.contains("401") || error_str.contains("unauthorized") {
10780 tracing::error!("Unauthorized - check API credentials and token validity");
10781 } else if error_str.contains("403") || error_str.contains("forbidden") {
10782 tracing::error!("Forbidden - check permissions for asset distribution");
10783 } else if error_str.contains("429") || error_str.contains("rate limit") {
10784 tracing::error!("Rate limited - wait before retrying");
10785 } else if error_str.contains("500") || error_str.contains("internal server") {
10786 tracing::error!(
10787 "Server error - this may be a temporary issue, retry may help"
10788 );
10789 }
10790
10791 AmpError::api(error_msg)
10792 },
10793 )?;
10794
10795 let api_call_duration = api_call_start.elapsed();
10796 tracing::info!(
10797 "Successfully created distribution: {} (took {:?})",
10798 response.distribution_uuid,
10799 api_call_duration
10800 );
10801
10802 // Validate response data
10803 if response.distribution_uuid.is_empty() {
10804 tracing::error!("API returned empty distribution UUID");
10805 return Err(AmpError::api("API returned empty distribution UUID"));
10806 }
10807
10808 if response.asset_id.is_empty() {
10809 tracing::error!("API returned empty asset ID");
10810 return Err(AmpError::api("API returned empty asset ID"));
10811 }
10812
10813 if response.map_address_amount.is_empty() {
10814 tracing::error!("API returned empty address mapping");
10815 return Err(AmpError::api("API returned empty address mapping"));
10816 }
10817
10818 tracing::debug!(
10819 "Distribution response validated - {} addresses mapped, asset_id: {}",
10820 response.map_address_amount.len(),
10821 response.asset_id
10822 );
10823
10824 Ok(response)
10825 }
10826
10827 /// Confirms a distribution with transaction and change data.
10828 ///
10829 /// This method submits the final confirmation for a distribution by providing
10830 /// the transaction details and any change UTXOs to the AMP API. This completes
10831 /// the distribution workflow after the transaction has been broadcast and confirmed
10832 /// on the blockchain.
10833 ///
10834 /// # Arguments
10835 /// * `asset_uuid` - The UUID of the asset being distributed
10836 /// * `distribution_uuid` - The UUID of the distribution to confirm (from `create_distribution` response)
10837 /// * `tx_data` - Transaction data containing details and txid from the blockchain
10838 /// * `change_data` - Vector of change UTXOs from the transaction
10839 ///
10840 /// # Errors
10841 /// Returns an error if:
10842 /// - Authentication fails
10843 /// - The asset UUID or distribution UUID is invalid
10844 /// - The transaction data is invalid or incomplete
10845 /// - The HTTP request fails
10846 /// - The server returns an error status
10847 /// - The response cannot be parsed
10848 ///
10849 /// # Examples
10850 /// ```no_run
10851 /// # use amp_rs::{ApiClient, model::{AmpTxData, Unspent}, AmpError};
10852 /// # #[tokio::main]
10853 /// # async fn main() -> Result<(), AmpError> {
10854 /// # let client = ApiClient::new().await?;
10855 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
10856 /// let distribution_uuid = "dist-550e8400-e29b-41d4-a716-446655440000";
10857 ///
10858 /// // Transaction data for AMP API confirmation
10859 /// let tx_data = AmpTxData {
10860 /// details: serde_json::json!([{
10861 /// "account": "",
10862 /// "address": "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq",
10863 /// "category": "send",
10864 /// "amount": -100.0,
10865 /// "vout": 0,
10866 /// "fee": -0.001
10867 /// }]),
10868 /// txid: "abc123def456...".to_string(),
10869 /// };
10870 ///
10871 /// // Change UTXOs from Elements node listunspent call
10872 /// let change_data = vec![
10873 /// Unspent {
10874 /// txid: "abc123def456...".to_string(),
10875 /// vout: 1,
10876 /// amount: 25.0,
10877 /// asset: "asset_id_hex".to_string(),
10878 /// address: "change_address".to_string(),
10879 /// spendable: true,
10880 /// confirmations: Some(2),
10881 /// scriptpubkey: Some("76a914...88ac".to_string()),
10882 /// redeemscript: None,
10883 /// witnessscript: None,
10884 /// amountblinder: None,
10885 /// assetblinder: None,
10886 /// }
10887 /// ];
10888 ///
10889 /// client.confirm_distribution(asset_uuid, distribution_uuid, tx_data, change_data).await?;
10890 /// println!("Distribution confirmed successfully");
10891 /// # Ok(())
10892 /// # }
10893 /// ```
10894 ///
10895 /// # Related Methods
10896 /// - [`create_distribution`](Self::create_distribution) - Create a new distribution
10897 /// - [`get_asset_assignments`](Self::get_asset_assignments) - List assignments for an asset
10898 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
10899 pub async fn confirm_distribution(
10900 &self,
10901 asset_uuid: &str,
10902 distribution_uuid: &str,
10903 tx_data: crate::model::AmpTxData,
10904 change_data: Vec<crate::model::Unspent>,
10905 ) -> Result<(), AmpError> {
10906 use crate::model::ConfirmDistributionRequest;
10907
10908 let confirm_span = tracing::debug_span!(
10909 "confirm_distribution",
10910 asset_uuid = %asset_uuid,
10911 distribution_uuid = %distribution_uuid,
10912 txid = %tx_data.txid,
10913 change_count = change_data.len()
10914 );
10915 let _enter = confirm_span.enter();
10916
10917 tracing::debug!(
10918 "Confirming distribution {} for asset {} with txid {} ({} change UTXOs)",
10919 distribution_uuid,
10920 asset_uuid,
10921 tx_data.txid,
10922 change_data.len()
10923 );
10924
10925 // Validate inputs
10926 if asset_uuid.is_empty() {
10927 tracing::error!("Distribution confirmation failed: empty asset UUID");
10928 return Err(AmpError::validation("Asset UUID cannot be empty"));
10929 }
10930
10931 if distribution_uuid.is_empty() {
10932 tracing::error!("Distribution confirmation failed: empty distribution UUID");
10933 return Err(AmpError::validation("Distribution UUID cannot be empty"));
10934 }
10935
10936 if tx_data.txid.is_empty() {
10937 tracing::error!("Distribution confirmation failed: empty transaction ID");
10938 return Err(AmpError::validation("Transaction ID cannot be empty"));
10939 }
10940
10941 // Log transaction details for debugging
10942 tracing::debug!("Transaction details array: {:?}", tx_data.details);
10943
10944 // Log change data details
10945 if change_data.is_empty() {
10946 tracing::debug!("No change UTXOs to include in confirmation");
10947 } else {
10948 let total_change: f64 = change_data.iter().map(|utxo| utxo.amount).sum();
10949 tracing::debug!(
10950 "Change data - {} UTXOs, total amount: {}",
10951 change_data.len(),
10952 total_change
10953 );
10954
10955 for (i, utxo) in change_data.iter().enumerate() {
10956 tracing::trace!(
10957 "Change UTXO {}: txid={}, vout={}, amount={}, spendable={}",
10958 i,
10959 utxo.txid,
10960 utxo.vout,
10961 utxo.amount,
10962 utxo.spendable
10963 );
10964 }
10965 }
10966
10967 let request = ConfirmDistributionRequest {
10968 tx_data: tx_data.clone(),
10969 change_data: change_data.clone(),
10970 };
10971
10972 tracing::debug!("Sending distribution confirmation request to AMP API");
10973 let api_call_start = std::time::Instant::now();
10974
10975 // Make the API call
10976 self.request_empty(
10977 Method::POST,
10978 &["assets", asset_uuid, "distributions", distribution_uuid, "confirm"],
10979 Some(&request),
10980 )
10981 .await
10982 .map_err(#[allow(clippy::cognitive_complexity)] |e| {
10983 let api_call_duration = api_call_start.elapsed();
10984 let error_msg = format!(
10985 "Failed to confirm distribution {} after {:?}: {}. IMPORTANT: Transaction {} was successful on blockchain. Use this txid to manually retry confirmation.",
10986 distribution_uuid, api_call_duration, e, tx_data.txid
10987 );
10988 tracing::error!("{}", error_msg);
10989
10990 // Check for specific API error patterns
10991 let error_str = e.to_string();
10992 if error_str.contains("404") || error_str.contains("not found") {
10993 tracing::error!("Distribution {} not found - verify distribution UUID is correct", distribution_uuid);
10994 } else if error_str.contains("400") || error_str.contains("bad request") {
10995 tracing::error!("Bad request - check transaction data format and change data");
10996 } else if error_str.contains("409") || error_str.contains("conflict") {
10997 tracing::error!("Conflict - distribution may already be confirmed");
10998 } else if error_str.contains("422") || error_str.contains("unprocessable") {
10999 tracing::error!("Unprocessable entity - check transaction confirmations and data validity");
11000 } else if error_str.contains("500") || error_str.contains("internal server") {
11001 tracing::error!("Server error - this may be a temporary issue, retry with txid: {}", tx_data.txid);
11002 }
11003
11004 AmpError::api(error_msg)
11005 })?;
11006
11007 let api_call_duration = api_call_start.elapsed();
11008 tracing::info!(
11009 "Successfully confirmed distribution: {} for asset: {} with txid: {} (took {:?})",
11010 distribution_uuid,
11011 asset_uuid,
11012 tx_data.txid,
11013 api_call_duration
11014 );
11015
11016 Ok(())
11017 }
11018
11019 /// Cancels an in-progress distribution for an asset.
11020 ///
11021 /// This method cancels a distribution that is currently in progress (unconfirmed status).
11022 /// Once a distribution is cancelled, it cannot be confirmed and the assigned amounts
11023 /// become available for new distributions.
11024 ///
11025 /// # Arguments
11026 /// * `asset_uuid` - The UUID of the asset
11027 /// * `distribution_uuid` - The UUID of the distribution to cancel
11028 ///
11029 /// # Returns
11030 /// Returns `Ok(())` if the distribution was successfully cancelled.
11031 ///
11032 /// # Errors
11033 /// Returns an error if:
11034 /// - Authentication fails
11035 /// - The HTTP request fails
11036 /// - The server returns an error status
11037 /// - The distribution is not found
11038 /// - The distribution is already confirmed and cannot be cancelled
11039 ///
11040 /// # Examples
11041 /// ```no_run
11042 /// use amp_rs::ApiClient;
11043 ///
11044 /// #[tokio::main]
11045 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
11046 /// let client = ApiClient::new().await?;
11047 ///
11048 /// client.cancel_distribution(
11049 /// "asset-uuid-123",
11050 /// "distribution-uuid-456"
11051 /// ).await?;
11052 ///
11053 /// println!("Distribution cancelled successfully");
11054 /// Ok(())
11055 /// # }
11056 /// ```
11057 #[allow(clippy::cognitive_complexity)]
11058 pub async fn cancel_distribution(
11059 &self,
11060 asset_uuid: &str,
11061 distribution_uuid: &str,
11062 ) -> Result<(), AmpError> {
11063 let cancel_span = tracing::debug_span!(
11064 "cancel_distribution",
11065 asset_uuid = %asset_uuid,
11066 distribution_uuid = %distribution_uuid
11067 );
11068 let _enter = cancel_span.enter();
11069
11070 tracing::debug!(
11071 "Cancelling distribution {} for asset {}",
11072 distribution_uuid,
11073 asset_uuid
11074 );
11075
11076 // Validate inputs
11077 if asset_uuid.is_empty() {
11078 tracing::error!("Distribution cancellation failed: empty asset UUID");
11079 return Err(AmpError::validation("Asset UUID cannot be empty"));
11080 }
11081
11082 if distribution_uuid.is_empty() {
11083 tracing::error!("Distribution cancellation failed: empty distribution UUID");
11084 return Err(AmpError::validation("Distribution UUID cannot be empty"));
11085 }
11086
11087 let api_call_start = std::time::Instant::now();
11088
11089 self.request_empty(
11090 Method::DELETE,
11091 &[
11092 "assets",
11093 asset_uuid,
11094 "distributions",
11095 distribution_uuid,
11096 "cancel",
11097 ],
11098 None::<&()>,
11099 )
11100 .await
11101 .map_err(|e| {
11102 let api_call_duration = api_call_start.elapsed();
11103 let error_msg = format!(
11104 "Failed to cancel distribution {distribution_uuid} for asset {asset_uuid} after {api_call_duration:?}: {e}"
11105 );
11106 tracing::error!("{}", error_msg);
11107
11108 // Check for specific API error patterns
11109 let error_str = e.to_string();
11110 if error_str.contains("404") || error_str.contains("not found") {
11111 tracing::error!(
11112 "Distribution {} not found - verify distribution UUID is correct",
11113 distribution_uuid
11114 );
11115 } else if error_str.contains("400") || error_str.contains("bad request") {
11116 tracing::error!("Bad request - distribution may already be confirmed or invalid");
11117 } else if error_str.contains("409") || error_str.contains("conflict") {
11118 tracing::error!(
11119 "Conflict - distribution may already be confirmed and cannot be cancelled"
11120 );
11121 } else if error_str.contains("422") || error_str.contains("unprocessable") {
11122 tracing::error!(
11123 "Unprocessable entity - distribution is in a state that cannot be cancelled"
11124 );
11125 }
11126
11127 AmpError::api(error_msg)
11128 })?;
11129
11130 let api_call_duration = api_call_start.elapsed();
11131 tracing::info!(
11132 "Successfully cancelled distribution: {} for asset: {} (took {:?})",
11133 distribution_uuid,
11134 asset_uuid,
11135 api_call_duration
11136 );
11137
11138 Ok(())
11139 }
11140
11141 /// Gets all distributions for a specific asset.
11142 ///
11143 /// This method retrieves all distributions (both confirmed and unconfirmed) for the specified asset.
11144 /// This is useful for checking if there are any in-progress distributions before deleting an asset.
11145 ///
11146 /// # Arguments
11147 /// * `asset_uuid` - The UUID of the asset to get distributions for
11148 ///
11149 /// # Returns
11150 /// Returns a vector of `Distribution` objects for the asset.
11151 ///
11152 /// # Errors
11153 /// Returns an error if:
11154 /// - Authentication fails
11155 /// - The HTTP request fails
11156 /// - The server returns an error status
11157 /// - The response cannot be parsed
11158 ///
11159 /// # Examples
11160 /// ```no_run
11161 /// use amp_rs::ApiClient;
11162 ///
11163 /// #[tokio::main]
11164 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
11165 /// let client = ApiClient::new().await?;
11166 ///
11167 /// let distributions = client.get_asset_distributions("asset-uuid-123").await?;
11168 ///
11169 /// for distribution in distributions {
11170 /// println!("Distribution: {} - Status: {:?}",
11171 /// distribution.distribution_uuid,
11172 /// distribution.distribution_status);
11173 /// }
11174 /// Ok(())
11175 /// }
11176 /// ```
11177 pub async fn get_asset_distributions(
11178 &self,
11179 asset_uuid: &str,
11180 ) -> Result<Vec<crate::model::Distribution>, Error> {
11181 let distributions_span = tracing::debug_span!(
11182 "get_asset_distributions",
11183 asset_uuid = %asset_uuid
11184 );
11185 let _enter = distributions_span.enter();
11186
11187 tracing::debug!("Getting distributions for asset {}", asset_uuid);
11188
11189 // Validate input
11190 if asset_uuid.is_empty() {
11191 tracing::error!("Get distributions failed: empty asset UUID");
11192 return Err(Error::RequestFailed(
11193 "Asset UUID cannot be empty".to_string(),
11194 ));
11195 }
11196
11197 self.request_json(
11198 Method::GET,
11199 &["assets", asset_uuid, "distributions"],
11200 None::<&()>,
11201 )
11202 .await
11203 }
11204
11205 /// Gets a specific distribution by UUID for an asset.
11206 ///
11207 /// This method retrieves detailed information about a specific distribution,
11208 /// including its status, UUID, and associated transactions.
11209 ///
11210 /// # Arguments
11211 /// * `asset_uuid` - The UUID of the asset
11212 /// * `distribution_uuid` - The UUID of the distribution to retrieve
11213 ///
11214 /// # Returns
11215 /// Returns a `Distribution` struct containing:
11216 /// - `distribution_uuid` - The unique identifier for the distribution
11217 /// - `distribution_status` - Current status of the distribution
11218 /// - `transactions` - List of transactions associated with the distribution
11219 ///
11220 /// # Errors
11221 /// Returns an error if:
11222 /// - Authentication fails
11223 /// - The HTTP request fails
11224 /// - The server returns an error status
11225 /// - The response cannot be parsed as JSON
11226 /// - The asset UUID or distribution UUID is empty
11227 ///
11228 /// # Examples
11229 /// ```no_run
11230 /// # use amp_rs::ApiClient;
11231 /// # #[tokio::main]
11232 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
11233 /// let client = ApiClient::new().await?;
11234 ///
11235 /// let distribution = client.get_asset_distribution(
11236 /// "asset-uuid-123",
11237 /// "distribution-uuid-456"
11238 /// ).await?;
11239 ///
11240 /// println!("Distribution: {} - Status: {:?}",
11241 /// distribution.distribution_uuid,
11242 /// distribution.distribution_status);
11243 /// # Ok(())
11244 /// # }
11245 /// ```
11246 ///
11247 /// # Related Methods
11248 /// - [`get_asset_distributions`](Self::get_asset_distributions) - List all distributions for an asset
11249 /// - [`create_distribution`](Self::create_distribution) - Create a new distribution
11250 /// - [`confirm_distribution`](Self::confirm_distribution) - Confirm a distribution
11251 /// - [`cancel_distribution`](Self::cancel_distribution) - Cancel a distribution
11252 #[allow(clippy::cognitive_complexity)]
11253 pub async fn get_asset_distribution(
11254 &self,
11255 asset_uuid: &str,
11256 distribution_uuid: &str,
11257 ) -> Result<crate::model::Distribution, Error> {
11258 let distribution_span = tracing::debug_span!(
11259 "get_asset_distribution",
11260 asset_uuid = %asset_uuid,
11261 distribution_uuid = %distribution_uuid
11262 );
11263 let _enter = distribution_span.enter();
11264
11265 tracing::debug!(
11266 "Getting distribution {} for asset {}",
11267 distribution_uuid,
11268 asset_uuid
11269 );
11270
11271 // Validate inputs
11272 if asset_uuid.is_empty() {
11273 tracing::error!("Get distribution failed: empty asset UUID");
11274 return Err(Error::RequestFailed(
11275 "Asset UUID cannot be empty".to_string(),
11276 ));
11277 }
11278
11279 if distribution_uuid.is_empty() {
11280 tracing::error!("Get distribution failed: empty distribution UUID");
11281 return Err(Error::RequestFailed(
11282 "Distribution UUID cannot be empty".to_string(),
11283 ));
11284 }
11285
11286 self.request_json(
11287 Method::GET,
11288 &["assets", asset_uuid, "distributions", distribution_uuid],
11289 None::<&()>,
11290 )
11291 .await
11292 }
11293
11294 /// Requests reissuance data for an asset
11295 ///
11296 /// This method creates a reissuance request with the AMP API and returns
11297 /// the necessary data to execute the reissuance transaction, including
11298 /// asset information, amount, and required UTXOs.
11299 ///
11300 /// # Arguments
11301 /// * `asset_uuid` - The UUID of the asset to reissue
11302 /// * `amount_to_reissue` - The amount to reissue (in satoshis for the asset)
11303 ///
11304 /// # Returns
11305 /// Returns a `ReissueRequestResponse` containing:
11306 /// - `command` - The command type ("reissue")
11307 /// - `min_supported_client_script_version` - Minimum script version required
11308 /// - `base_url` - Base URL for the AMP API
11309 /// - `asset_uuid` - The asset UUID
11310 /// - `asset_id` - The asset ID (hex string)
11311 /// - `amount` - The amount to reissue
11312 /// - `reissuance_utxos` - List of required reissuance token UTXOs
11313 ///
11314 /// # Errors
11315 /// Returns an `AmpError` if:
11316 /// - Authentication fails or insufficient permissions
11317 /// - The asset UUID is invalid or does not exist
11318 /// - The asset is not reissuable
11319 /// - Insufficient reissuance tokens are available
11320 /// - The HTTP request fails
11321 /// - The server returns an error status
11322 /// - The response cannot be parsed
11323 ///
11324 /// # Examples
11325 /// ```no_run
11326 /// # use amp_rs::{ApiClient, AmpError};
11327 /// # #[tokio::main]
11328 /// # async fn main() -> Result<(), AmpError> {
11329 /// let client = ApiClient::new().await.map_err(AmpError::from)?;
11330 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
11331 /// let amount = 1000000; // 0.01 of an asset with 8 decimals
11332 ///
11333 /// let response = client.reissue_request(asset_uuid, amount).await?;
11334 /// println!("Reissuance request created for asset: {}", response.asset_id);
11335 /// println!("Amount to reissue: {}", response.amount);
11336 /// # Ok(())
11337 /// # }
11338 /// ```
11339 ///
11340 /// # Related Methods
11341 /// - [`reissue_confirm`](Self::reissue_confirm) - Confirm a completed reissuance
11342 /// - [`reissue_asset`](Self::reissue_asset) - Complete reissuance workflow
11343 #[allow(clippy::cognitive_complexity)]
11344 pub async fn reissue_request(
11345 &self,
11346 asset_uuid: &str,
11347 amount_to_reissue: i64,
11348 ) -> Result<crate::model::ReissueRequestResponse, AmpError> {
11349 use crate::model::ReissueRequest;
11350
11351 let request_span = tracing::debug_span!("reissue_request", asset_uuid = %asset_uuid);
11352 let _enter = request_span.enter();
11353
11354 tracing::debug!(
11355 "Creating reissuance request for asset {} with amount {}",
11356 asset_uuid,
11357 amount_to_reissue
11358 );
11359
11360 // Validate inputs
11361 if asset_uuid.is_empty() {
11362 tracing::error!("Reissuance request failed: empty asset UUID");
11363 return Err(AmpError::validation("Asset UUID cannot be empty"));
11364 }
11365
11366 if amount_to_reissue <= 0 {
11367 tracing::error!(
11368 "Reissuance request failed: invalid amount {}",
11369 amount_to_reissue
11370 );
11371 return Err(AmpError::validation("Amount to reissue must be positive"));
11372 }
11373
11374 let request = ReissueRequest { amount_to_reissue };
11375
11376 let response: crate::model::ReissueRequestResponse = self
11377 .request_json(
11378 Method::POST,
11379 &["assets", asset_uuid, "reissue-request"],
11380 Some(&request),
11381 )
11382 .await
11383 .map_err(|e| {
11384 tracing::error!("Reissuance request failed: {}", e);
11385 AmpError::api(format!("Failed to create reissuance request: {e}"))
11386 .with_context("Reissuance request creation")
11387 })?;
11388
11389 tracing::info!(
11390 "Reissuance request created successfully: asset_id={}, amount={}",
11391 response.asset_id,
11392 response.amount
11393 );
11394
11395 Ok(response)
11396 }
11397
11398 /// Confirms a completed reissuance transaction
11399 ///
11400 /// This method confirms a reissuance transaction that has been broadcast
11401 /// to the Elements network. It provides the transaction details and issuance
11402 /// information to the AMP API to register the reissuance.
11403 ///
11404 /// # Arguments
11405 /// * `asset_uuid` - The UUID of the asset that was reissued
11406 /// * `details` - Transaction details from `gettransaction` RPC call (as JSON Value)
11407 /// * `listissuances` - List of issuances from `listissuances` RPC call for this transaction
11408 /// * `reissuance_output` - Reissuance output containing txid and vin (as JSON Value)
11409 ///
11410 /// # Returns
11411 /// Returns a `ReissueResponse` containing:
11412 /// - `txid` - The transaction ID
11413 /// - `vin` - The input index of the reissuance
11414 /// - `reissuance_amount` - The amount that was reissued
11415 ///
11416 /// # Errors
11417 /// Returns an `AmpError` if:
11418 /// - Authentication fails or insufficient permissions
11419 /// - The asset UUID is invalid or does not exist
11420 /// - The transaction data is invalid or incomplete
11421 /// - The reissuance transaction is not valid
11422 /// - The HTTP request fails
11423 /// - The server returns an error status
11424 /// - The response cannot be parsed
11425 ///
11426 /// # Examples
11427 /// ```no_run
11428 /// # use amp_rs::{ApiClient, AmpError, ElementsRpc};
11429 /// # use serde_json::json;
11430 /// # #[tokio::main]
11431 /// # async fn main() -> Result<(), AmpError> {
11432 /// let client = ApiClient::new().await.map_err(AmpError::from)?;
11433 /// let rpc = ElementsRpc::from_env()?;
11434 ///
11435 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
11436 /// let txid = "abc123...";
11437 ///
11438 /// // Get transaction details
11439 /// let tx_detail = rpc.get_transaction(txid).await?;
11440 /// let details = serde_json::to_value(&tx_detail.details).unwrap();
11441 ///
11442 /// // Get issuances for this transaction
11443 /// let issuances = rpc.list_issuances(None).await?;
11444 /// let listissuances: Vec<_> = issuances
11445 /// .into_iter()
11446 /// .filter(|i| i.get("txid").and_then(|v| v.as_str()) == Some(txid))
11447 /// .collect();
11448 ///
11449 /// let reissuance_output = json!({"txid": txid, "vin": 0});
11450 ///
11451 /// let response = client.reissue_confirm(
11452 /// asset_uuid,
11453 /// details,
11454 /// listissuances,
11455 /// reissuance_output,
11456 /// ).await?;
11457 ///
11458 /// println!("Reissuance confirmed: txid={}, vin={}", response.txid, response.vin);
11459 /// # Ok(())
11460 /// # }
11461 /// ```
11462 ///
11463 /// # Related Methods
11464 /// - [`reissue_request`](Self::reissue_request) - Create a reissuance request
11465 /// - [`reissue_asset`](Self::reissue_asset) - Complete reissuance workflow
11466 #[allow(clippy::cognitive_complexity)]
11467 pub async fn reissue_confirm(
11468 &self,
11469 asset_uuid: &str,
11470 details: serde_json::Value,
11471 listissuances: Vec<serde_json::Value>,
11472 reissuance_output: serde_json::Value,
11473 ) -> Result<crate::model::ReissueResponse, AmpError> {
11474 use crate::model::ReissueConfirmRequest;
11475
11476 let confirm_span = tracing::debug_span!("reissue_confirm", asset_uuid = %asset_uuid);
11477 let _enter = confirm_span.enter();
11478
11479 // Extract txid for logging (clone to avoid borrow checker issue)
11480 let txid = reissuance_output
11481 .get("txid")
11482 .and_then(serde_json::Value::as_str)
11483 .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string);
11484 let vin = reissuance_output
11485 .get("vin")
11486 .and_then(serde_json::Value::as_u64)
11487 .unwrap_or(0);
11488
11489 tracing::debug!(
11490 "Confirming reissuance for asset {} with txid {} vin {} ({} issuances)",
11491 asset_uuid,
11492 txid,
11493 vin,
11494 listissuances.len()
11495 );
11496
11497 // Validate inputs
11498 if asset_uuid.is_empty() {
11499 tracing::error!("Reissuance confirmation failed: empty asset UUID");
11500 return Err(AmpError::validation("Asset UUID cannot be empty"));
11501 }
11502
11503 let request = ReissueConfirmRequest {
11504 details,
11505 listissuances,
11506 reissuance_output,
11507 };
11508
11509 let response: crate::model::ReissueResponse = self
11510 .request_json(
11511 Method::POST,
11512 &["assets", asset_uuid, "reissue-confirm"],
11513 Some(&request),
11514 )
11515 .await
11516 .map_err(|e| {
11517 tracing::error!("Reissuance confirmation failed: {}", e);
11518 AmpError::api(format!(
11519 "Failed to confirm reissuance for txid {}: {}. \
11520 IMPORTANT: Transaction {} was successful on blockchain. \
11521 You may need to retry confirmation with this txid.",
11522 &txid, e, &txid
11523 ))
11524 .with_context("Reissuance confirmation")
11525 })?;
11526
11527 tracing::info!(
11528 "Reissuance confirmed successfully: txid={}, vin={}, amount={}",
11529 response.txid,
11530 response.vin,
11531 response.reissuance_amount
11532 );
11533
11534 Ok(response)
11535 }
11536
11537 /// Creates a burn request for an asset
11538 ///
11539 /// This method requests the data needed to burn (destroy) a specific amount of an asset.
11540 /// The response contains UTXOs that need to be available in the wallet for the burn operation.
11541 ///
11542 /// # Arguments
11543 /// * `asset_uuid` - The UUID of the asset to burn
11544 /// * `amount` - The amount to burn (in satoshis for the asset)
11545 ///
11546 /// # Returns
11547 /// Returns a `BurnCreate` containing:
11548 /// - Asset information (UUID, asset ID)
11549 /// - Amount to burn
11550 /// - Required UTXOs that must be available in the wallet
11551 ///
11552 /// # Errors
11553 /// Returns an `AmpError` if:
11554 /// - The asset UUID is invalid or empty
11555 /// - The amount is invalid (non-positive)
11556 /// - Authentication fails or insufficient permissions
11557 /// - The asset does not exist
11558 /// - The HTTP request fails
11559 /// - The server returns an error status
11560 ///
11561 /// # Examples
11562 /// ```no_run
11563 /// # use amp_rs::{ApiClient, AmpError};
11564 /// # #[tokio::main]
11565 /// # async fn main() -> Result<(), AmpError> {
11566 /// let client = ApiClient::new().await?;
11567 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
11568 /// let amount = 1000000; // 0.01 of an asset with 8 decimals
11569 ///
11570 /// let response = client.burn_request(asset_uuid, amount).await?;
11571 /// println!("Burn request created for asset: {}", response.asset_id);
11572 /// println!("Amount to burn: {}", response.amount);
11573 /// println!("Required UTXOs: {:?}", response.utxos);
11574 /// # Ok(())
11575 /// # }
11576 /// ```
11577 ///
11578 /// # Related Methods
11579 /// - [`burn_confirm`](Self::burn_confirm) - Confirm a burn transaction
11580 /// - [`burn_asset`](Self::burn_asset) - Complete burn workflow
11581 #[allow(clippy::cognitive_complexity)]
11582 pub async fn burn_request(
11583 &self,
11584 asset_uuid: &str,
11585 amount: i64,
11586 ) -> Result<crate::model::BurnCreate, AmpError> {
11587 use crate::model::BurnRequest;
11588
11589 let request_span = tracing::debug_span!("burn_request", asset_uuid = %asset_uuid);
11590 let _enter = request_span.enter();
11591
11592 tracing::debug!(
11593 "Creating burn request for asset {} with amount {}",
11594 asset_uuid,
11595 amount
11596 );
11597
11598 // Validate inputs
11599 if asset_uuid.is_empty() {
11600 tracing::error!("Burn request failed: empty asset UUID");
11601 return Err(AmpError::validation("Asset UUID cannot be empty"));
11602 }
11603
11604 if amount <= 0 {
11605 tracing::error!("Burn request failed: invalid amount {}", amount);
11606 return Err(AmpError::validation("Amount to burn must be positive"));
11607 }
11608
11609 let request = BurnRequest { amount };
11610
11611 let response: crate::model::BurnCreate = self
11612 .request_json(
11613 Method::POST,
11614 &["assets", asset_uuid, "burn-request"],
11615 Some(&request),
11616 )
11617 .await
11618 .map_err(|e| {
11619 tracing::error!("Burn request failed: {}", e);
11620 AmpError::api(format!("Failed to create burn request: {e}"))
11621 .with_context("Burn request creation")
11622 })?;
11623
11624 tracing::info!(
11625 "Burn request created successfully: asset_id={}, amount={}",
11626 response.asset_id,
11627 response.amount
11628 );
11629
11630 Ok(response)
11631 }
11632
11633 /// Confirms a completed burn transaction
11634 ///
11635 /// This method confirms a burn transaction that has been broadcast
11636 /// to the Elements network. It provides the transaction details and
11637 /// change data to complete the burn registration with the AMP API.
11638 ///
11639 /// # Arguments
11640 /// * `asset_uuid` - The UUID of the asset that was burned
11641 /// * `tx_data` - Transaction data from `gettransaction` RPC call (as JSON Value, containing at least txid)
11642 /// * `change_data` - Change data from `listunspent` RPC call filtered by `asset_id` and txid (as JSON Values)
11643 ///
11644 /// # Returns
11645 /// Returns `Ok(())` on success (the API returns an empty response with status 200)
11646 ///
11647 /// # Errors
11648 /// Returns an `AmpError` if:
11649 /// - Authentication fails or insufficient permissions
11650 /// - The asset UUID is invalid or does not exist
11651 /// - The transaction data is invalid or incomplete
11652 /// - The burn transaction is not valid
11653 /// - The HTTP request fails
11654 /// - The server returns an error status
11655 ///
11656 /// # Examples
11657 /// ```no_run
11658 /// # use amp_rs::{ApiClient, AmpError};
11659 /// # #[tokio::main]
11660 /// # async fn main() -> Result<(), AmpError> {
11661 /// let client = ApiClient::new().await?;
11662 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
11663 ///
11664 /// let tx_data = serde_json::json!({
11665 /// "txid": "abc123def456..."
11666 /// });
11667 ///
11668 /// let change_data = vec![serde_json::json!({
11669 /// "txid": "abc123def456...",
11670 /// "vout": 0,
11671 /// "address": "tlq1qq...",
11672 /// "amount": 100.0,
11673 /// "asset": "asset_id_here",
11674 /// "spendable": true
11675 /// })];
11676 ///
11677 /// client.burn_confirm(asset_uuid, tx_data, change_data).await?;
11678 /// println!("Burn confirmed successfully");
11679 /// # Ok(())
11680 /// # }
11681 /// ```
11682 ///
11683 /// # Related Methods
11684 /// - [`burn_request`](Self::burn_request) - Create a burn request
11685 /// - [`burn_asset`](Self::burn_asset) - Complete burn workflow
11686 #[allow(clippy::cognitive_complexity)]
11687 pub async fn burn_confirm(
11688 &self,
11689 asset_uuid: &str,
11690 tx_data: serde_json::Value,
11691 change_data: Vec<serde_json::Value>,
11692 ) -> Result<(), AmpError> {
11693 use crate::model::BurnConfirmRequest;
11694
11695 let confirm_span = tracing::debug_span!("burn_confirm", asset_uuid = %asset_uuid);
11696 let _enter = confirm_span.enter();
11697
11698 // Extract txid for logging
11699 let txid = tx_data
11700 .get("txid")
11701 .and_then(serde_json::Value::as_str)
11702 .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string);
11703
11704 tracing::debug!(
11705 "Confirming burn for asset {} with txid {} ({} change outputs)",
11706 asset_uuid,
11707 txid,
11708 change_data.len()
11709 );
11710
11711 // Validate inputs
11712 if asset_uuid.is_empty() {
11713 tracing::error!("Burn confirmation failed: empty asset UUID");
11714 return Err(AmpError::validation("Asset UUID cannot be empty"));
11715 }
11716
11717 let request = BurnConfirmRequest {
11718 tx_data,
11719 change_data,
11720 };
11721
11722 // The burn-confirm endpoint returns 200 with empty body (no JSON response)
11723 self.request_empty(
11724 Method::POST,
11725 &["assets", asset_uuid, "burn-confirm"],
11726 Some(&request),
11727 )
11728 .await
11729 .map_err(|e| {
11730 tracing::error!("Burn confirmation failed: {}", e);
11731 AmpError::api(format!(
11732 "Failed to confirm burn for txid {}: {}. \
11733 IMPORTANT: Transaction {} was successful on blockchain. \
11734 You may need to retry confirmation with this txid.",
11735 &txid, e, &txid
11736 ))
11737 .with_context("Burn confirmation")
11738 })?;
11739
11740 tracing::info!("Burn confirmed successfully: txid={}", txid);
11741
11742 Ok(())
11743 }
11744
11745 /// Gets a specific manager by ID.
11746 ///
11747 /// # Arguments
11748 /// * `manager_id` - The ID of the manager to retrieve
11749 ///
11750 /// # Errors
11751 /// Returns an error if:
11752 /// - Authentication fails
11753 /// - The HTTP request fails
11754 /// - The server returns an error status
11755 /// - The response cannot be parsed as JSON
11756 pub async fn get_manager(&self, manager_id: i64) -> Result<crate::model::Manager, Error> {
11757 self.request_json(
11758 Method::GET,
11759 &["managers", &manager_id.to_string()],
11760 None::<&()>,
11761 )
11762 .await
11763 }
11764
11765 /// Removes a manager's permissions to modify a specific asset.
11766 ///
11767 /// This method revokes a manager's access to a specific asset, preventing them from
11768 /// performing asset management operations such as creating assignments, managing ownership,
11769 /// or modifying asset properties. The manager will no longer be able to access this asset
11770 /// through their management interface.
11771 ///
11772 /// # Arguments
11773 /// * `manager_id` - The ID of the manager to remove permissions from
11774 /// * `asset_uuid` - The UUID of the asset to remove permissions for
11775 ///
11776 /// # Returns
11777 /// Returns `Ok(())` on successful permission removal.
11778 ///
11779 /// # Errors
11780 /// Returns an error if:
11781 /// - Authentication fails or insufficient permissions
11782 /// - The manager ID is invalid or does not exist
11783 /// - The asset UUID is invalid or does not exist
11784 /// - The manager does not currently have permissions for this asset
11785 /// - The HTTP request fails
11786 /// - The server returns an error status
11787 ///
11788 /// # Examples
11789 /// ```no_run
11790 /// # use amp_rs::ApiClient;
11791 /// # #[tokio::main]
11792 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
11793 /// let client = ApiClient::new().await?;
11794 ///
11795 /// let manager_id = 123;
11796 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
11797 ///
11798 /// client.manager_remove_asset(manager_id, asset_uuid).await?;
11799 /// println!("Removed asset {} from manager {}", asset_uuid, manager_id);
11800 /// # Ok(())
11801 /// # }
11802 /// ```
11803 ///
11804 /// # Related Methods
11805 /// - [`add_asset_to_manager`](Self::add_asset_to_manager) - Grant manager permissions for an asset
11806 /// - [`get_manager`](Self::get_manager) - Get manager information including current assets
11807 /// - [`revoke_manager`](Self::revoke_manager) - Remove all asset permissions from manager
11808 /// - [`lock_manager`](Self::lock_manager) - Lock manager account
11809 pub async fn manager_remove_asset(
11810 &self,
11811 manager_id: i64,
11812 asset_uuid: &str,
11813 ) -> Result<(), Error> {
11814 self.request_empty(
11815 Method::POST,
11816 &[
11817 "managers",
11818 &manager_id.to_string(),
11819 "assets",
11820 asset_uuid,
11821 "remove",
11822 ],
11823 None::<&()>,
11824 )
11825 .await
11826 }
11827
11828 /// Revokes all asset permissions for a manager.
11829 ///
11830 /// This method first retrieves the manager's current asset permissions,
11831 /// then removes the manager's access to each asset they currently have access to.
11832 ///
11833 /// # Arguments
11834 /// * `manager_id` - The ID of the manager to revoke permissions for
11835 ///
11836 /// # Errors
11837 /// Returns an error if:
11838 /// - Authentication fails
11839 /// - The HTTP request fails
11840 /// - The server returns an error status
11841 /// - Any individual asset removal fails
11842 pub async fn revoke_manager(&self, manager_id: i64) -> Result<(), Error> {
11843 // First, get the manager to see which assets they have access to
11844 let manager = self.get_manager(manager_id).await?;
11845
11846 // Remove the manager's access to each asset
11847 for asset_uuid in &manager.assets {
11848 self.manager_remove_asset(manager_id, asset_uuid).await?;
11849 }
11850
11851 Ok(())
11852 }
11853
11854 /// Gets the current manager information as raw JSON.
11855 ///
11856 /// This method calls the `/managers/me` endpoint to retrieve information
11857 /// about the currently authenticated manager.
11858 ///
11859 /// # Errors
11860 /// Returns an error if:
11861 /// - Authentication fails
11862 /// - The HTTP request fails
11863 /// - The server returns an error status
11864 /// - The response cannot be parsed as JSON
11865 pub async fn get_current_manager_raw(&self) -> Result<serde_json::Value, Error> {
11866 self.request_json(Method::GET, &["managers", "me"], None::<&()>)
11867 .await
11868 }
11869
11870 /// Locks a manager account to prevent further operations.
11871 ///
11872 /// This method sends a PUT request to lock the specified manager, preventing any further
11873 /// operations on that manager account. This is typically used for security purposes or
11874 /// when a manager needs to be temporarily disabled.
11875 ///
11876 /// # Arguments
11877 /// * `manager_id` - The ID of the manager to lock
11878 ///
11879 /// # Returns
11880 /// Returns `Ok(())` if the manager was successfully locked.
11881 ///
11882 /// # Errors
11883 /// Returns an error if:
11884 /// - Authentication fails
11885 /// - The HTTP request fails
11886 /// - The server returns an error status
11887 /// - The manager ID is invalid or does not exist
11888 /// - The manager is already locked
11889 ///
11890 /// # Example
11891 /// ```no_run
11892 /// # use amp_rs::ApiClient;
11893 /// # #[tokio::main]
11894 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
11895 /// let client = ApiClient::new().await?;
11896 ///
11897 /// // Lock manager with ID 123
11898 /// client.lock_manager(123).await?;
11899 /// println!("Manager 123 has been locked successfully");
11900 /// # Ok(())
11901 /// # }
11902 /// ```
11903 pub async fn lock_manager(&self, manager_id: i64) -> Result<(), Error> {
11904 self.request_empty(
11905 Method::PUT,
11906 &["managers", &manager_id.to_string(), "lock"],
11907 None::<&()>,
11908 )
11909 .await
11910 }
11911
11912 /// Unlocks a manager account.
11913 ///
11914 /// # Arguments
11915 /// * `manager_id` - The ID of the manager to unlock
11916 ///
11917 /// # Errors
11918 /// Returns an error if:
11919 /// - Authentication fails
11920 /// - The HTTP request fails
11921 /// - The server returns an error status
11922 pub async fn unlock_manager(&self, manager_id: i64) -> Result<(), Error> {
11923 self.request_empty(
11924 Method::PUT,
11925 &["managers", &manager_id.to_string(), "unlock"],
11926 None::<&()>,
11927 )
11928 .await
11929 }
11930
11931 /// Authorizes a manager to manage a specific asset.
11932 ///
11933 /// This method sends a PUT request to authorize the specified manager to manage the given asset.
11934 /// Once authorized, the manager will have permissions to perform operations on the asset such as
11935 /// creating assignments, managing ownership, and other asset-related operations.
11936 ///
11937 /// # Arguments
11938 /// * `manager_id` - The ID of the manager to authorize
11939 /// * `asset_uuid` - The UUID of the asset to add to the manager's authorized assets
11940 ///
11941 /// # Returns
11942 /// Returns `Ok(())` if the manager was successfully authorized for the asset.
11943 ///
11944 /// # Errors
11945 /// Returns an error if:
11946 /// - Authentication fails or insufficient permissions
11947 /// - The HTTP request fails
11948 /// - The server returns an error status
11949 /// - The manager ID is invalid or does not exist
11950 /// - The asset UUID is invalid or does not exist
11951 /// - The manager is already authorized for this asset
11952 /// - The manager is locked and cannot be modified
11953 ///
11954 /// # Examples
11955 /// ```no_run
11956 /// # use amp_rs::ApiClient;
11957 /// # #[tokio::main]
11958 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
11959 /// let client = ApiClient::new().await?;
11960 ///
11961 /// // Authorize manager 123 to manage asset with UUID "550e8400-e29b-41d4-a716-446655440000"
11962 /// let manager_id = 123;
11963 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
11964 ///
11965 /// client.add_asset_to_manager(manager_id, asset_uuid).await?;
11966 /// println!("Manager {} is now authorized to manage asset {}", manager_id, asset_uuid);
11967 /// # Ok(())
11968 /// # }
11969 /// ```
11970 ///
11971 /// # Related Methods
11972 /// - [`manager_remove_asset`](Self::manager_remove_asset) - Remove manager permissions for an asset
11973 /// - [`get_manager`](Self::get_manager) - Get manager information including current assets
11974 /// - [`get_manager_permissions`](Self::get_manager_permissions) - Get manager's current permissions
11975 /// - [`lock_manager`](Self::lock_manager) - Lock manager account
11976 pub async fn add_asset_to_manager(
11977 &self,
11978 manager_id: i64,
11979 asset_uuid: &str,
11980 ) -> Result<(), Error> {
11981 self.request_empty(
11982 Method::PUT,
11983 &[
11984 "managers",
11985 &manager_id.to_string(),
11986 "assets",
11987 asset_uuid,
11988 "add",
11989 ],
11990 None::<&()>,
11991 )
11992 .await
11993 }
11994
11995 /// Deletes a specific asset assignment.
11996 ///
11997 /// # Arguments
11998 /// * `asset_uuid` - The UUID of the asset
11999 /// * `assignment_id` - The ID of the assignment to delete
12000 ///
12001 /// # Errors
12002 /// Returns an error if:
12003 /// - Authentication fails
12004 /// - The HTTP request fails
12005 /// - The server returns an error status
12006 /// Removes an asset assignment.
12007 ///
12008 /// This method permanently deletes an asset assignment, returning the allocated assets
12009 /// back to the available pool. This operation cannot be undone. If the assignment has
12010 /// already been distributed, this operation may fail.
12011 ///
12012 /// # Arguments
12013 /// * `asset_uuid` - The UUID of the asset containing the assignment
12014 /// * `assignment_id` - The ID of the assignment to delete
12015 ///
12016 /// # Returns
12017 /// Returns `Ok(())` on successful deletion.
12018 ///
12019 /// # Errors
12020 /// Returns an error if:
12021 /// - Authentication fails or insufficient permissions
12022 /// - The asset UUID is invalid or does not exist
12023 /// - The assignment ID is invalid or does not exist
12024 /// - The assignment has already been distributed and cannot be deleted
12025 /// - The assignment is locked and cannot be modified
12026 /// - The HTTP request fails
12027 /// - The server returns an error status
12028 ///
12029 /// # Examples
12030 /// ```no_run
12031 /// # use amp_rs::ApiClient;
12032 /// # #[tokio::main]
12033 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
12034 /// let client = ApiClient::new().await?;
12035 ///
12036 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
12037 /// let assignment_id = "123";
12038 ///
12039 /// client.delete_asset_assignment(asset_uuid, assignment_id).await?;
12040 /// println!("Successfully deleted assignment {}", assignment_id);
12041 /// # Ok(())
12042 /// # }
12043 /// ```
12044 ///
12045 /// # Related Methods
12046 /// - [`get_asset_assignment`](Self::get_asset_assignment) - Get assignment details before deletion
12047 /// - [`create_asset_assignments`](Self::create_asset_assignments) - Create new assignments
12048 /// - [`edit_asset_assignment`](Self::edit_asset_assignment) - Update assignment instead of deleting
12049 /// - [`lock_asset_assignment`](Self::lock_asset_assignment) - Lock assignment to prevent changes
12050 pub async fn delete_asset_assignment(
12051 &self,
12052 asset_uuid: &str,
12053 assignment_id: &str,
12054 ) -> Result<(), Error> {
12055 self.request_empty(
12056 Method::DELETE,
12057 &["assets", asset_uuid, "assignments", assignment_id, "delete"],
12058 None::<&()>,
12059 )
12060 .await
12061 }
12062
12063 /// Locks a specific asset assignment.
12064 ///
12065 /// # Arguments
12066 /// * `asset_uuid` - The UUID of the asset
12067 /// * `assignment_id` - The ID of the assignment to lock
12068 ///
12069 /// # Errors
12070 /// Returns an error if:
12071 /// - Authentication fails
12072 /// - The HTTP request fails
12073 /// - The server returns an error status
12074 pub async fn lock_asset_assignment(
12075 &self,
12076 asset_uuid: &str,
12077 assignment_id: &str,
12078 ) -> Result<Assignment, Error> {
12079 self.request_json(
12080 Method::PUT,
12081 &["assets", asset_uuid, "assignments", assignment_id, "lock"],
12082 None::<&()>,
12083 )
12084 .await
12085 }
12086
12087 /// Unlocks a specific asset assignment.
12088 ///
12089 /// # Arguments
12090 /// * `asset_uuid` - The UUID of the asset
12091 /// * `assignment_id` - The ID of the assignment to unlock
12092 ///
12093 /// # Errors
12094 /// Returns an error if:
12095 /// - Authentication fails
12096 /// - The HTTP request fails
12097 /// - The server returns an error status
12098 pub async fn unlock_asset_assignment(
12099 &self,
12100 asset_uuid: &str,
12101 assignment_id: &str,
12102 ) -> Result<Assignment, Error> {
12103 self.request_json(
12104 Method::PUT,
12105 &["assets", asset_uuid, "assignments", assignment_id, "unlock"],
12106 None::<&()>,
12107 )
12108 .await
12109 }
12110
12111 /// Adds categories to a registered user.
12112 ///
12113 /// # Arguments
12114 /// * `registered_user_id` - The ID of the registered user
12115 /// * `categories` - A slice of category IDs to add to the user
12116 ///
12117 /// # Errors
12118 /// Returns an error if:
12119 /// - Authentication fails
12120 /// - The HTTP request fails
12121 /// - The server returns an error status
12122 /// - The registered user ID is invalid
12123 /// - Any category ID is invalid
12124 pub async fn add_categories_to_registered_user(
12125 &self,
12126 registered_user_id: i64,
12127 categories: &[i64],
12128 ) -> Result<(), Error> {
12129 let request_body = CategoriesRequest {
12130 categories: categories.to_vec(),
12131 };
12132
12133 self.request_empty(
12134 Method::PUT,
12135 &[
12136 "registered_users",
12137 ®istered_user_id.to_string(),
12138 "categories",
12139 "add",
12140 ],
12141 Some(request_body),
12142 )
12143 .await
12144 }
12145
12146 /// Removes categories from a registered user
12147 ///
12148 /// # Arguments
12149 /// * `registered_user_id` - The ID of the registered user
12150 /// * `categories` - A slice of category IDs to remove from the user
12151 ///
12152 /// # Returns
12153 /// Returns `Ok(())` if the categories are successfully removed, or an error if:
12154 /// - Authentication fails
12155 /// - The HTTP request fails
12156 /// - The server returns an error status
12157 /// - The registered user ID is invalid
12158 /// - Any category ID is not associated with the user
12159 pub async fn remove_categories_from_registered_user(
12160 &self,
12161 registered_user_id: i64,
12162 categories: &[i64],
12163 ) -> Result<(), Error> {
12164 let request_body = CategoriesRequest {
12165 categories: categories.to_vec(),
12166 };
12167
12168 self.request_empty(
12169 Method::PUT,
12170 &[
12171 "registered_users",
12172 ®istered_user_id.to_string(),
12173 "categories",
12174 "delete",
12175 ],
12176 Some(request_body),
12177 )
12178 .await
12179 }
12180
12181 /// Distributes assets to multiple users through a comprehensive workflow
12182 ///
12183 /// This method orchestrates the complete asset distribution process:
12184 /// 1. Validates input parameters (asset UUID format, assignments structure)
12185 /// 2. Verifies `ElementsRpc` connection and signer interface availability
12186 /// 3. Authenticates with the AMP API using the client's token
12187 /// 4. Creates a distribution request via the AMP API
12188 /// 5. Constructs and signs the blockchain transaction using the provided signer
12189 /// 6. Broadcasts the transaction to the Elements network
12190 /// 7. Waits for blockchain confirmations (2 confirmations minimum)
12191 /// 8. Confirms the distribution with the AMP API
12192 ///
12193 /// # Arguments
12194 /// * `asset_uuid` - The UUID of the asset to distribute (must be valid UUID format)
12195 /// * `assignments` - Vector of assignments specifying `user_id`, address, and amount
12196 /// * `node_rpc` - `ElementsRpc` client for blockchain operations
12197 /// * `signer` - Signer implementation for transaction signing
12198 ///
12199 /// # Returns
12200 /// Returns `Ok(())` if the distribution completes successfully, or an `AmpError` if:
12201 /// - Input validation fails (invalid UUID format, empty assignments, etc.)
12202 /// - `ElementsRpc` connection cannot be established
12203 /// - Signer interface is not available
12204 /// - Authentication with AMP API fails
12205 /// - Distribution creation fails
12206 /// - Transaction construction or signing fails
12207 /// - Blockchain broadcasting fails
12208 /// - Confirmation timeout occurs
12209 /// - Distribution confirmation with AMP API fails
12210 ///
12211 /// # Examples
12212 /// ```no_run
12213 /// # use amp_rs::{ApiClient, ElementsRpc, AmpError};
12214 /// # use amp_rs::model::AssetDistributionAssignment;
12215 /// # use amp_rs::signer::{Signer, LwkSoftwareSigner};
12216 /// # #[tokio::main]
12217 /// # async fn main() -> Result<(), AmpError> {
12218 /// let client = ApiClient::new().await?;
12219 /// let elements_rpc = ElementsRpc::from_env()?;
12220 /// let (_, signer) = LwkSoftwareSigner::generate_new()?;
12221 ///
12222 /// let assignments = vec![
12223 /// AssetDistributionAssignment {
12224 /// user_id: "user123".to_string(),
12225 /// address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
12226 /// amount: 100.0,
12227 /// },
12228 /// ];
12229 ///
12230 /// client.distribute_asset(
12231 /// "550e8400-e29b-41d4-a716-446655440000",
12232 /// assignments,
12233 /// &elements_rpc,
12234 /// "wallet_name",
12235 /// &signer
12236 /// ).await?;
12237 /// # Ok(())
12238 /// # }
12239 /// ```
12240 ///
12241 /// # Requirements
12242 /// This method implements requirements:
12243 /// - 1.1: Single method for complete distribution workflow
12244 /// - 2.2: Assignment details validation
12245 /// - 2.4: Input validation for all parameters
12246 /// - 5.1: Comprehensive error handling with context
12247 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
12248 pub async fn distribute_asset(
12249 &self,
12250 asset_uuid: &str,
12251 assignments: Vec<AssetDistributionAssignment>,
12252 node_rpc: &ElementsRpc,
12253 wallet_name: &str,
12254 signer: &dyn Signer,
12255 ) -> Result<(), AmpError> {
12256 let distribution_span = tracing::info_span!(
12257 "distribute_asset",
12258 asset_uuid = %asset_uuid,
12259 assignment_count = assignments.len()
12260 );
12261 let _enter = distribution_span.enter();
12262
12263 tracing::info!(
12264 "Starting asset distribution workflow for asset: {} with {} assignments",
12265 asset_uuid,
12266 assignments.len()
12267 );
12268
12269 // Step 1: Input validation - asset_uuid format
12270 tracing::debug!("Step 1: Validating asset UUID format");
12271 Self::validate_asset_uuid(asset_uuid).map_err(|e| {
12272 let error = AmpError::validation(format!("Invalid asset UUID: {e}"));
12273 tracing::error!("Asset UUID validation failed: {}", e);
12274 error.with_context("Step 1: Asset UUID validation")
12275 })?;
12276 tracing::debug!("Asset UUID validation passed");
12277
12278 // Step 2: Input validation - assignments data structure
12279 tracing::debug!("Step 2: Validating {} assignments", assignments.len());
12280 Self::validate_assignments(&assignments).map_err(|e| {
12281 let error = AmpError::validation(format!("Invalid assignments: {e}"));
12282 tracing::error!("Assignments validation failed: {}", e);
12283 error.with_context("Step 2: Assignments validation")
12284 })?;
12285 tracing::debug!("Assignments validation passed");
12286
12287 // Step 3: Check ElementsRpc connection availability
12288 tracing::debug!("Step 3: Validating Elements RPC connection");
12289 self.validate_elements_rpc_connection(node_rpc)
12290 .await
12291 .map_err(|e| {
12292 let error = AmpError::rpc(format!("ElementsRpc connection validation failed: {e}"));
12293 tracing::error!("Elements RPC connection validation failed: {}", e);
12294 error.with_context("Step 3: Elements RPC connection validation")
12295 })?;
12296 tracing::debug!("Elements RPC connection validation passed");
12297
12298 // Step 4: Check signer interface availability
12299 tracing::debug!("Step 4: Validating signer interface");
12300 self.validate_signer_interface(signer).await.map_err(|e| {
12301 let error = AmpError::validation(format!("Signer interface validation failed: {e}"));
12302 tracing::error!("Signer interface validation failed: {}", e);
12303 error.with_context("Step 4: Signer interface validation")
12304 })?;
12305 tracing::debug!("Signer interface validation passed");
12306
12307 tracing::info!("✓ All input validations completed successfully");
12308
12309 // Step 5: Authenticate with AMP API using existing TokenManager
12310 tracing::debug!("Step 5: Authenticating with AMP API");
12311 let _token = self.token_strategy.get_token().await.map_err(|e| {
12312 tracing::error!("AMP API authentication failed: {}", e);
12313 let amp_error = AmpError::Existing(e);
12314 if amp_error.is_retryable() {
12315 if let Some(instructions) = amp_error.retry_instructions() {
12316 tracing::warn!("Retry instructions: {}", instructions);
12317 }
12318 }
12319 amp_error.with_context("Step 5: AMP API authentication")
12320 })?;
12321 tracing::info!("✓ Successfully authenticated with AMP API");
12322
12323 // Step 6: Create distribution request and parse response data
12324 tracing::debug!(
12325 "Step 6: Creating distribution request with {} assignments",
12326 assignments.len()
12327 );
12328 let distribution_response = self
12329 .create_distribution(asset_uuid, assignments)
12330 .await
12331 .map_err(|e| {
12332 tracing::error!("Distribution creation failed: {}", e);
12333 if e.is_retryable() {
12334 if let Some(instructions) = e.retry_instructions() {
12335 tracing::warn!("Retry instructions: {}", instructions);
12336 }
12337 }
12338 e.with_context("Step 6: Distribution creation")
12339 })?;
12340
12341 tracing::info!(
12342 "✓ Distribution created successfully: {} with asset_id: {}",
12343 distribution_response.distribution_uuid,
12344 distribution_response.asset_id
12345 );
12346
12347 // Step 7: Verify Elements node status and execute transaction workflow
12348 tracing::debug!("Step 7: Verifying Elements node status");
12349 let (network_info, blockchain_info) = node_rpc.get_node_status().await.map_err(|e| {
12350 tracing::error!("Elements node status verification failed: {}", e);
12351 if e.is_retryable() {
12352 if let Some(instructions) = e.retry_instructions() {
12353 tracing::warn!("Retry instructions: {}", instructions);
12354 }
12355 }
12356 e.with_context("Step 7: Elements node status verification")
12357 })?;
12358
12359 tracing::info!(
12360 "✓ Elements node verified - chain: {}, blocks: {}, connections: {}",
12361 blockchain_info.chain,
12362 blockchain_info.blocks,
12363 network_info.connections
12364 );
12365
12366 // Step 8: Send distribution transaction using Elements' sendmany
12367 tracing::debug!("Step 8: Sending distribution transaction using Elements sendmany");
12368
12369 // Create asset amounts map for sendmany (all outputs use the same asset)
12370 let mut asset_amounts = std::collections::HashMap::new();
12371 for address in distribution_response.map_address_amount.keys() {
12372 asset_amounts.insert(address.clone(), distribution_response.asset_id.clone());
12373 }
12374
12375 tracing::info!(
12376 "Using sendmany for {} outputs with asset {}",
12377 distribution_response.map_address_amount.len(),
12378 distribution_response.asset_id
12379 );
12380
12381 // Use Elements' sendmany which properly handles confidential transactions
12382 let txid = node_rpc
12383 .sendmany(
12384 wallet_name,
12385 distribution_response.map_address_amount.clone(),
12386 asset_amounts,
12387 Some(0), // min_conf: 0 to include unconfirmed UTXOs (matches Python implementation)
12388 Some("AMP asset distribution"), // comment
12389 None, // subtract_fee_from: let Elements handle fees automatically
12390 Some(false), // replaceable: false for final transactions
12391 Some(1), // conf_target: 1 block for faster confirmation
12392 Some("UNSET"), // estimate_mode: let Elements choose
12393 )
12394 .await
12395 .map_err(|e| {
12396 tracing::error!("Sendmany transaction failed: {}", e);
12397 if e.is_retryable() {
12398 if let Some(instructions) = e.retry_instructions() {
12399 tracing::warn!("Retry instructions: {}", instructions);
12400 }
12401 }
12402 e.with_context("Step 8: Sendmany transaction")
12403 })?;
12404
12405 tracing::info!("✓ Transaction sent successfully with ID: {}", txid);
12406
12407 // Step 9: Wait for confirmations
12408 tracing::debug!("Step 9: Waiting for blockchain confirmations (minimum 2 confirmations, 10-minute timeout)");
12409 let confirmation_start = std::time::Instant::now();
12410 let tx_detail = node_rpc.wait_for_confirmations(&txid, Some(2), Some(10)).await
12411 .map_err(|e| {
12412 let elapsed = confirmation_start.elapsed();
12413 tracing::error!(
12414 "Confirmation waiting failed after {:?}: {}",
12415 elapsed,
12416 e
12417 );
12418
12419 if let AmpError::Timeout(_) = &e {
12420 tracing::warn!(
12421 "Confirmation timeout - transaction {} may still be pending. \
12422 Use this txid to manually confirm the distribution if it gets confirmed later.",
12423 txid
12424 );
12425 let timeout_error = AmpError::timeout(format!(
12426 "Confirmation timeout for txid: {txid}. Use this txid to manually confirm the distribution."
12427 ));
12428 timeout_error.with_context("Step 9: Confirmation waiting")
12429 } else {
12430 if e.is_retryable() {
12431 if let Some(instructions) = e.retry_instructions() {
12432 tracing::warn!("Retry instructions: {}", instructions);
12433 }
12434 }
12435 e.with_context(format!("Step 9: Confirmation waiting for txid: {txid}"))
12436 }
12437 })?;
12438
12439 let confirmation_duration = confirmation_start.elapsed();
12440 tracing::info!(
12441 "✓ Transaction confirmed with {} confirmations at block height: {:?} (took {:?})",
12442 tx_detail.confirmations,
12443 tx_detail.blockheight,
12444 confirmation_duration
12445 );
12446
12447 // Step 10: Collect change data for confirmation
12448 tracing::debug!("Step 10: Collecting change data for distribution confirmation");
12449 let change_data = node_rpc
12450 .collect_change_data(
12451 &distribution_response.asset_id,
12452 &txid,
12453 node_rpc,
12454 wallet_name,
12455 )
12456 .await
12457 .map_err(|e| {
12458 tracing::error!("Change data collection failed: {}", e);
12459 if e.is_retryable() {
12460 if let Some(instructions) = e.retry_instructions() {
12461 tracing::warn!("Retry instructions: {}", instructions);
12462 }
12463 }
12464 e.with_context("Step 10: Change data collection")
12465 })?;
12466
12467 tracing::info!("✓ Collected {} change UTXOs", change_data.len());
12468 if !change_data.is_empty() {
12469 tracing::debug!("Change UTXOs: {:?}", change_data);
12470 }
12471
12472 // Step 11: Submit final confirmation to AMP API
12473 tracing::debug!("Step 11: Submitting final confirmation to AMP API");
12474
12475 // Extract the details field from the transaction (matching Python implementation)
12476 // Python: details = rpc.call('gettransaction', txid).get('details')
12477 let transaction_details = tx_detail.details.unwrap_or_else(Vec::new);
12478 tracing::debug!(
12479 "Transaction details for confirmation: {:?}",
12480 transaction_details
12481 );
12482
12483 let amp_tx_data = crate::model::AmpTxData {
12484 details: serde_json::Value::Array(transaction_details),
12485 txid: txid.clone(),
12486 };
12487
12488 // Log the exact payload being sent to AMP for debugging
12489 tracing::info!("Sending confirmation payload to AMP:");
12490 tracing::info!(" tx_data.txid: {}", amp_tx_data.txid);
12491 tracing::info!(" tx_data.details: {:?}", amp_tx_data.details);
12492 tracing::info!(" change_data: {} UTXOs", change_data.len());
12493
12494 let confirmation_request = crate::model::ConfirmDistributionRequest {
12495 tx_data: amp_tx_data.clone(),
12496 change_data: change_data.clone(),
12497 };
12498
12499 if let Ok(payload_json) = serde_json::to_string_pretty(&confirmation_request) {
12500 tracing::debug!("Full confirmation payload: {}", payload_json);
12501 }
12502
12503 self.confirm_distribution(
12504 asset_uuid,
12505 &distribution_response.distribution_uuid,
12506 amp_tx_data,
12507 change_data,
12508 )
12509 .await
12510 .map_err(|e| {
12511 tracing::error!("Distribution confirmation failed: {}", e);
12512
12513 // For confirmation failures, always provide retry instructions with txid
12514 let confirmation_error = AmpError::api(format!(
12515 "Failed to confirm distribution {}: {}. \
12516 IMPORTANT: Transaction {} was successful on blockchain. \
12517 Use this txid to manually retry confirmation.",
12518 distribution_response.distribution_uuid, e, txid
12519 ));
12520
12521 if e.is_retryable() {
12522 if let Some(instructions) = e.retry_instructions() {
12523 tracing::warn!("Retry instructions: {}", instructions);
12524 }
12525 }
12526
12527 confirmation_error.with_context("Step 11: Distribution confirmation")
12528 })?;
12529
12530 tracing::info!(
12531 "🎉 Asset distribution completed successfully for asset: {} with transaction: {}",
12532 asset_uuid,
12533 txid
12534 );
12535
12536 Ok(())
12537 }
12538
12539 /// Reissues an asset through a comprehensive workflow
12540 ///
12541 /// This method orchestrates the complete asset reissuance process:
12542 /// 1. Validates input parameters (asset UUID format, amount)
12543 /// 2. Verifies `ElementsRpc` connection and signer interface availability
12544 /// 3. Authenticates with the AMP API using the client's token
12545 /// 4. Creates a reissuance request via the AMP API
12546 /// 5. Waits for transaction propagation and checks for lost outputs
12547 /// 6. Verifies reissuance token UTXOs are available
12548 /// 7. Calls the Elements node's `reissueasset` RPC method
12549 /// 8. Waits for blockchain confirmations (2 confirmations minimum)
12550 /// 9. Retrieves transaction details and issuance information
12551 /// 10. Confirms the reissuance with the AMP API
12552 ///
12553 /// # Arguments
12554 /// * `asset_uuid` - The UUID of the asset to reissue (must be valid UUID format)
12555 /// * `amount_to_reissue` - The amount to reissue (in satoshis for the asset)
12556 /// * `node_rpc` - `ElementsRpc` client for blockchain operations
12557 /// * `signer` - Signer implementation for future support (currently not used, node RPC signs)
12558 ///
12559 /// # Returns
12560 /// Returns `Ok(())` if the reissuance completes successfully, or an `AmpError` if:
12561 /// - Input validation fails (invalid UUID format, invalid amount, etc.)
12562 /// - `ElementsRpc` connection cannot be established
12563 /// - Signer interface is not available
12564 /// - Authentication with AMP API fails
12565 /// - Reissuance request creation fails
12566 /// - Lost outputs are detected
12567 /// - Required UTXOs are not available
12568 /// - Reissuance transaction creation fails
12569 /// - Confirmation timeout occurs
12570 /// - Reissuance confirmation with AMP API fails
12571 ///
12572 /// # Examples
12573 /// ```no_run
12574 /// # use amp_rs::{ApiClient, ElementsRpc, AmpError};
12575 /// # use amp_rs::signer::LwkSoftwareSigner;
12576 /// # #[tokio::main]
12577 /// # async fn main() -> Result<(), AmpError> {
12578 /// let client = ApiClient::new().await?;
12579 /// let elements_rpc = ElementsRpc::from_env()?;
12580 /// let (_, signer) = LwkSoftwareSigner::generate_new()?;
12581 ///
12582 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
12583 /// let amount = 1000000; // 0.01 of an asset with 8 decimals
12584 ///
12585 /// client.reissue_asset(asset_uuid, amount, &elements_rpc, &signer).await?;
12586 /// println!("Reissuance completed successfully");
12587 /// # Ok(())
12588 /// # }
12589 /// ```
12590 ///
12591 /// # Related Methods
12592 /// - [`reissue_request`](Self::reissue_request) - Create a reissuance request only
12593 /// - [`reissue_confirm`](Self::reissue_confirm) - Confirm a reissuance transaction only
12594 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
12595 pub async fn reissue_asset(
12596 &self,
12597 asset_uuid: &str,
12598 amount_to_reissue: i64,
12599 node_rpc: &ElementsRpc,
12600 signer: &dyn Signer,
12601 ) -> Result<(), AmpError> {
12602 let reissue_span = tracing::info_span!(
12603 "reissue_asset",
12604 asset_uuid = %asset_uuid,
12605 amount_to_reissue = amount_to_reissue
12606 );
12607 let _enter = reissue_span.enter();
12608
12609 tracing::info!(
12610 "Starting asset reissuance workflow for asset: {} with amount: {}",
12611 asset_uuid,
12612 amount_to_reissue
12613 );
12614
12615 // Step 1: Input validation - asset_uuid format
12616 tracing::debug!("Step 1: Validating asset UUID format");
12617 Self::validate_asset_uuid(asset_uuid).map_err(|e| {
12618 let error = AmpError::validation(format!("Invalid asset UUID: {e}"));
12619 tracing::error!("Asset UUID validation failed: {}", e);
12620 error.with_context("Step 1: Asset UUID validation")
12621 })?;
12622 tracing::debug!("Asset UUID validation passed");
12623
12624 // Step 2: Input validation - amount
12625 tracing::debug!("Step 2: Validating reissuance amount");
12626 if amount_to_reissue <= 0 {
12627 let error = AmpError::validation("Amount to reissue must be positive".to_string());
12628 tracing::error!("Amount validation failed: amount must be positive");
12629 return Err(error.with_context("Step 2: Amount validation"));
12630 }
12631 tracing::debug!("Amount validation passed");
12632
12633 // Step 3: Check ElementsRpc connection availability
12634 tracing::debug!("Step 3: Validating Elements RPC connection");
12635 self.validate_elements_rpc_connection(node_rpc)
12636 .await
12637 .map_err(|e| {
12638 let error = AmpError::rpc(format!("ElementsRpc connection validation failed: {e}"));
12639 tracing::error!("Elements RPC connection validation failed: {}", e);
12640 error.with_context("Step 3: Elements RPC connection validation")
12641 })?;
12642 tracing::debug!("Elements RPC connection validation passed");
12643
12644 // Step 4: Check signer interface availability (for future support)
12645 tracing::debug!("Step 4: Validating signer interface");
12646 self.validate_signer_interface(signer).await.map_err(|e| {
12647 let error = AmpError::validation(format!("Signer interface validation failed: {e}"));
12648 tracing::error!("Signer interface validation failed: {}", e);
12649 error.with_context("Step 4: Signer interface validation")
12650 })?;
12651 tracing::debug!("Signer interface validation passed");
12652
12653 tracing::info!("✓ All input validations completed successfully");
12654
12655 // Step 5: Authenticate with AMP API using existing TokenManager
12656 tracing::debug!("Step 5: Authenticating with AMP API");
12657 let _token = self.token_strategy.get_token().await.map_err(|e| {
12658 tracing::error!("AMP API authentication failed: {}", e);
12659 let amp_error = AmpError::Existing(e);
12660 if amp_error.is_retryable() {
12661 if let Some(instructions) = amp_error.retry_instructions() {
12662 tracing::warn!("Retry instructions: {}", instructions);
12663 }
12664 }
12665 amp_error.with_context("Step 5: AMP API authentication")
12666 })?;
12667 tracing::info!("✓ Successfully authenticated with AMP API");
12668
12669 // Step 6: Create reissuance request and parse response data
12670 tracing::debug!(
12671 "Step 6: Creating reissuance request with amount {}",
12672 amount_to_reissue
12673 );
12674 let reissue_response = self
12675 .reissue_request(asset_uuid, amount_to_reissue)
12676 .await
12677 .map_err(|e| {
12678 tracing::error!("Reissuance request creation failed: {}", e);
12679 if e.is_retryable() {
12680 if let Some(instructions) = e.retry_instructions() {
12681 tracing::warn!("Retry instructions: {}", instructions);
12682 }
12683 }
12684 e.with_context("Step 6: Reissuance request creation")
12685 })?;
12686
12687 tracing::info!(
12688 "✓ Reissuance request created successfully: asset_id={}, amount={}",
12689 reissue_response.asset_id,
12690 reissue_response.amount
12691 );
12692
12693 // Step 7: Verify Elements node status
12694 tracing::debug!("Step 7: Verifying Elements node status");
12695 let (network_info, blockchain_info) = node_rpc.get_node_status().await.map_err(|e| {
12696 tracing::error!("Elements node status verification failed: {}", e);
12697 if e.is_retryable() {
12698 if let Some(instructions) = e.retry_instructions() {
12699 tracing::warn!("Retry instructions: {}", instructions);
12700 }
12701 }
12702 e.with_context("Step 7: Elements node status verification")
12703 })?;
12704
12705 tracing::info!(
12706 "✓ Elements node verified - chain: {}, blocks: {}, connections: {}",
12707 blockchain_info.chain,
12708 blockchain_info.blocks,
12709 network_info.connections
12710 );
12711
12712 // Step 8: Wait for transaction propagation (60 seconds as per Python script)
12713 tracing::debug!("Step 8: Waiting for transaction propagation (60 seconds)");
12714 tracing::info!("Waiting 60 seconds for transaction propagation...");
12715 tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
12716 tracing::debug!("Transaction propagation wait completed");
12717
12718 // Step 9: Check for lost outputs
12719 tracing::debug!("Step 9: Checking for lost outputs");
12720 let balance_response: serde_json::Value = self
12721 .request_json(Method::GET, &["assets", asset_uuid, "balance"], None::<&()>)
12722 .await
12723 .map_err(|e| {
12724 tracing::error!("Failed to check lost outputs: {}", e);
12725 AmpError::api(format!("Balance check failed: {e}"))
12726 .with_context("Step 9: Lost outputs check")
12727 })?;
12728
12729 // Check if lost_outputs field exists and is not empty
12730 if let Some(lost_outputs) = balance_response.get("lost_outputs") {
12731 if let Some(lost_outputs_array) = lost_outputs.as_array() {
12732 if !lost_outputs_array.is_empty() {
12733 let error_msg = format!(
12734 "Lost outputs detected: {}. Transaction will not be sent.",
12735 serde_json::to_string(&lost_outputs_array).unwrap_or_default()
12736 );
12737 tracing::error!("{}", error_msg);
12738 return Err(AmpError::api(error_msg).with_context("Step 9: Lost outputs check"));
12739 }
12740 }
12741 }
12742
12743 tracing::info!("✓ No lost outputs detected");
12744
12745 // Step 10: Check UTXOs match reissuance_utxos from response
12746 tracing::debug!(
12747 "Step 10: Verifying {} reissuance token UTXOs are available",
12748 reissue_response.reissuance_utxos.len()
12749 );
12750
12751 let available_utxos = node_rpc.list_unspent(None).await.map_err(|e| {
12752 tracing::error!("Failed to list UTXOs: {}", e);
12753 AmpError::rpc(format!("Failed to list UTXOs: {e}"))
12754 .with_context("Step 10: UTXO verification")
12755 })?;
12756
12757 // Check that all required reissuance UTXOs are available
12758 let local_utxos: std::collections::HashSet<(String, i64)> = available_utxos
12759 .iter()
12760 .map(|utxo| (utxo.txid.clone(), i64::from(utxo.vout)))
12761 .collect();
12762
12763 let mut missing_utxos = Vec::new();
12764 for required_utxo in &reissue_response.reissuance_utxos {
12765 if !local_utxos.contains(&(required_utxo.txid.clone(), required_utxo.vout)) {
12766 missing_utxos.push(format!("{}:{}", required_utxo.txid, required_utxo.vout));
12767 }
12768 }
12769
12770 if !missing_utxos.is_empty() {
12771 let error_msg = format!(
12772 "Missing reissuance token UTXOs: {}. Ensure reissuance tokens are available in the wallet.",
12773 missing_utxos.join(", ")
12774 );
12775 tracing::error!("{}", error_msg);
12776 return Err(AmpError::rpc(error_msg).with_context("Step 10: UTXO verification"));
12777 }
12778
12779 tracing::info!(
12780 "✓ All {} reissuance token UTXOs are available",
12781 reissue_response.reissuance_utxos.len()
12782 );
12783
12784 // Step 11: Call Elements node's reissueasset RPC method
12785 tracing::debug!("Step 11: Calling Elements reissueasset RPC method");
12786 let reissuance_output = node_rpc
12787 .reissueasset(&reissue_response.asset_id, reissue_response.amount)
12788 .await
12789 .map_err(|e| {
12790 tracing::error!("Reissuance transaction creation failed: {}", e);
12791 if e.is_retryable() {
12792 if let Some(instructions) = e.retry_instructions() {
12793 tracing::warn!("Retry instructions: {}", instructions);
12794 }
12795 }
12796 e.with_context("Step 11: Reissuance transaction creation")
12797 })?;
12798
12799 // Extract txid and vin from reissuance output
12800 let txid = reissuance_output
12801 .get("txid")
12802 .and_then(|v| v.as_str())
12803 .ok_or_else(|| {
12804 AmpError::rpc("Reissuance output missing txid field".to_string())
12805 .with_context("Step 11: Reissuance transaction creation")
12806 })?;
12807 let vin = reissuance_output
12808 .get("vin")
12809 .and_then(serde_json::Value::as_u64)
12810 .ok_or_else(|| {
12811 AmpError::rpc("Reissuance output missing vin field".to_string())
12812 .with_context("Step 11: Reissuance transaction creation")
12813 })?;
12814
12815 tracing::info!(
12816 "✓ Reissuance transaction created: txid={}, vin={}",
12817 txid,
12818 vin
12819 );
12820
12821 // Step 12: Wait for confirmations
12822 tracing::debug!("Step 12: Waiting for blockchain confirmations (minimum 2 confirmations, 10-minute timeout)");
12823 let confirmation_start = std::time::Instant::now();
12824
12825 // First, wait for 1 confirmation before spawning treasury address task
12826 node_rpc
12827 .wait_for_confirmations(txid, Some(1), Some(10))
12828 .await
12829 .map_err(|e| {
12830 let elapsed = confirmation_start.elapsed();
12831 tracing::error!(
12832 "Confirmation waiting (1 conf) failed after {:?}: {}",
12833 elapsed,
12834 e
12835 );
12836 e.with_context(format!(
12837 "Step 12: Waiting for 1 confirmation for txid: {txid}"
12838 ))
12839 })?;
12840
12841 tracing::info!(
12842 "✓ Transaction has 1 confirmation, spawning treasury address extraction task"
12843 );
12844
12845 // Spawn async task to extract and submit reissuance token change address
12846 // This runs in parallel with the remaining confirmation wait
12847 let asset_uuid_clone = asset_uuid.to_string();
12848 let txid_clone = txid.to_string();
12849 let client_clone = self.clone();
12850 let node_rpc_clone = node_rpc.clone();
12851
12852 tokio::spawn(async move {
12853 if let Err(e) = client_clone
12854 .extract_and_submit_reissuance_token_change_address(
12855 &asset_uuid_clone,
12856 &txid_clone,
12857 &node_rpc_clone,
12858 )
12859 .await
12860 {
12861 tracing::warn!(
12862 "Failed to extract/submit reissuance token change address: {}. \
12863 This is non-critical and does not affect the reissuance operation.",
12864 e
12865 );
12866 }
12867 });
12868
12869 // Continue waiting for the full 2 confirmations
12870 let _tx_detail = node_rpc
12871 .wait_for_confirmations(txid, Some(2), Some(10))
12872 .await
12873 .map_err(|e| {
12874 let elapsed = confirmation_start.elapsed();
12875 tracing::error!(
12876 "Confirmation waiting failed after {:?}: {}",
12877 elapsed,
12878 e
12879 );
12880
12881 if let AmpError::Timeout(_) = &e {
12882 tracing::warn!(
12883 "Confirmation timeout - transaction {} may still be pending. \
12884 Use this txid to manually confirm the reissuance if it gets confirmed later.",
12885 txid
12886 );
12887 let timeout_error = AmpError::timeout(format!(
12888 "Confirmation timeout for txid: {txid}. Use this txid to manually confirm the reissuance."
12889 ));
12890 timeout_error.with_context("Step 12: Confirmation waiting")
12891 } else {
12892 if e.is_retryable() {
12893 if let Some(instructions) = e.retry_instructions() {
12894 tracing::warn!("Retry instructions: {}", instructions);
12895 }
12896 }
12897 e.with_context(format!("Step 12: Confirmation waiting for txid: {txid}"))
12898 }
12899 })?;
12900
12901 tracing::info!("✓ Transaction confirmed with at least 2 confirmations");
12902
12903 // Step 13: Get transaction details and issuance information
12904 tracing::debug!("Step 13: Retrieving transaction details and issuance information");
12905
12906 // Get transaction details
12907 let tx_detail = node_rpc.get_transaction(txid).await.map_err(|e| {
12908 tracing::error!("Failed to get transaction details: {}", e);
12909 AmpError::rpc(format!("Failed to get transaction details: {e}"))
12910 .with_context("Step 13: Transaction details retrieval")
12911 })?;
12912
12913 // Convert details to JSON Value
12914 let details = serde_json::to_value(tx_detail.details).map_err(|e| {
12915 tracing::error!("Failed to serialize transaction details: {}", e);
12916 AmpError::api(format!("Failed to serialize transaction details: {e}"))
12917 .with_context("Step 13: Transaction details serialization")
12918 })?;
12919
12920 // Get all issuances and filter by txid
12921 let all_issuances = node_rpc.list_issuances(None).await.map_err(|e| {
12922 tracing::error!("Failed to list issuances: {}", e);
12923 AmpError::rpc(format!("Failed to list issuances: {e}"))
12924 .with_context("Step 13: Issuance listing")
12925 })?;
12926
12927 let listissuances: Vec<serde_json::Value> = all_issuances
12928 .into_iter()
12929 .filter(|issuance| {
12930 issuance
12931 .get("txid")
12932 .and_then(serde_json::Value::as_str)
12933 .is_some_and(|tid| tid == txid)
12934 })
12935 .collect();
12936
12937 tracing::info!(
12938 "✓ Retrieved transaction details and {} issuance(s) for txid {}",
12939 listissuances.len(),
12940 txid
12941 );
12942
12943 // Step 14: Confirm reissuance with AMP API
12944 tracing::debug!("Step 14: Confirming reissuance with AMP API");
12945
12946 let reissuance_output_value = serde_json::json!({
12947 "txid": txid,
12948 "vin": vin
12949 });
12950
12951 self.reissue_confirm(asset_uuid, details, listissuances, reissuance_output_value)
12952 .await
12953 .map_err(|e| {
12954 tracing::error!("Reissuance confirmation failed: {}", e);
12955
12956 // For confirmation failures, always provide retry instructions with txid
12957 let confirmation_error = AmpError::api(format!(
12958 "Failed to confirm reissuance: {e}. \
12959 IMPORTANT: Transaction {txid} was successful on blockchain. \
12960 Use this txid to manually retry confirmation."
12961 ));
12962
12963 if e.is_retryable() {
12964 if let Some(instructions) = e.retry_instructions() {
12965 tracing::warn!("Retry instructions: {}", instructions);
12966 }
12967 }
12968
12969 confirmation_error.with_context("Step 14: Reissuance confirmation")
12970 })?;
12971
12972 tracing::info!(
12973 "🎉 Asset reissuance completed successfully for asset: {} with transaction: {}",
12974 asset_uuid,
12975 txid
12976 );
12977
12978 Ok(())
12979 }
12980
12981 /// Extracts the reissuance token change address from a reissuance transaction
12982 /// and submits it to the asset's treasury addresses list.
12983 ///
12984 /// This method is called asynchronously after a reissuance transaction has 1 confirmation.
12985 /// It runs in a separate task to avoid blocking the main reissuance flow.
12986 ///
12987 /// # Arguments
12988 /// * `asset_uuid` - The UUID of the asset that was reissued
12989 /// * `txid` - The transaction ID of the reissuance transaction
12990 /// * `node_rpc` - Elements RPC client to query transaction details
12991 ///
12992 /// # Returns
12993 /// Returns `Ok(())` if successful, or an error if extraction/submission fails.
12994 /// Errors are logged but do not affect the main reissuance operation.
12995 #[allow(clippy::too_many_lines, clippy::cognitive_complexity)]
12996 async fn extract_and_submit_reissuance_token_change_address(
12997 &self,
12998 asset_uuid: &str,
12999 txid: &str,
13000 node_rpc: &ElementsRpc,
13001 ) -> Result<(), AmpError> {
13002 tracing::info!(
13003 "[Treasury Address Task] Starting extraction for asset {} from txid {}",
13004 asset_uuid,
13005 txid
13006 );
13007
13008 // Get asset to retrieve reissuance token ID
13009 let asset = self.get_asset(asset_uuid).await.map_err(|e| {
13010 tracing::error!("[Treasury Address Task] Failed to get asset: {}", e);
13011 AmpError::api(format!("Failed to get asset: {e}"))
13012 })?;
13013
13014 let reissuance_token_id = asset.reissuance_token_id.as_ref().ok_or_else(|| {
13015 tracing::error!("[Treasury Address Task] Asset has no reissuance token ID");
13016 AmpError::validation("Asset has no reissuance token ID".to_string())
13017 })?;
13018
13019 tracing::debug!(
13020 "[Treasury Address Task] Looking for reissuance token ID: {}",
13021 reissuance_token_id
13022 );
13023
13024 // Get transaction details
13025 let tx_detail = node_rpc.get_transaction(txid).await.map_err(|e| {
13026 tracing::error!(
13027 "[Treasury Address Task] Failed to get transaction details: {}",
13028 e
13029 );
13030 e
13031 })?;
13032
13033 // Find the reissuance token change address
13034 let mut change_address: Option<String> = None;
13035 if let Some(details) = &tx_detail.details {
13036 tracing::debug!(
13037 "[Treasury Address Task] Scanning {} transaction detail entries",
13038 details.len()
13039 );
13040
13041 for (index, detail) in details.iter().enumerate() {
13042 if let (Some(category), Some(asset_id), Some(address)) = (
13043 detail.get("category").and_then(|v| v.as_str()),
13044 detail.get("asset").and_then(|v| v.as_str()),
13045 detail.get("address").and_then(|v| v.as_str()),
13046 ) {
13047 if category == "receive" && asset_id == reissuance_token_id {
13048 tracing::info!(
13049 "[Treasury Address Task] Found reissuance token receive address at index {}: {}",
13050 index,
13051 address
13052 );
13053 change_address = Some(address.to_string());
13054 break;
13055 }
13056 }
13057 }
13058 }
13059
13060 if let Some(address) = change_address {
13061 tracing::info!(
13062 "[Treasury Address Task] Extracted change address: {}",
13063 address
13064 );
13065
13066 // Check if address is already in treasury addresses
13067 let treasury_addresses = self
13068 .get_asset_treasury_addresses(asset_uuid)
13069 .await
13070 .map_err(|e| {
13071 tracing::error!(
13072 "[Treasury Address Task] Failed to get treasury addresses: {}",
13073 e
13074 );
13075 AmpError::api(format!("Failed to get treasury addresses: {e}"))
13076 })?;
13077
13078 if treasury_addresses.contains(&address) {
13079 tracing::info!(
13080 "[Treasury Address Task] Address {} is already in treasury addresses list",
13081 address
13082 );
13083 return Ok(());
13084 }
13085
13086 // Submit address to treasury addresses
13087 tracing::debug!(
13088 "[Treasury Address Task] Submitting address {} to treasury addresses",
13089 address
13090 );
13091
13092 self.add_asset_treasury_addresses(asset_uuid, std::slice::from_ref(&address))
13093 .await
13094 .map_err(|e| {
13095 tracing::error!(
13096 "[Treasury Address Task] Failed to add treasury address: {}",
13097 e
13098 );
13099 AmpError::api(format!("Failed to add treasury address: {e}"))
13100 })?;
13101
13102 tracing::info!(
13103 "[Treasury Address Task] Successfully added {} to treasury addresses for asset {}",
13104 address,
13105 asset_uuid
13106 );
13107
13108 Ok(())
13109 } else {
13110 tracing::warn!(
13111 "[Treasury Address Task] Could not find reissuance token change address in transaction {}. \
13112 This may be normal if all reissuance tokens were consumed.",
13113 txid
13114 );
13115 Err(AmpError::validation(
13116 "No reissuance token change address found".to_string(),
13117 ))
13118 }
13119 }
13120
13121 /// Burns (destroys) a specific amount of an asset
13122 ///
13123 /// This method orchestrates the complete burn workflow:
13124 /// 1. Validates input parameters (asset UUID format, amount)
13125 /// 2. Validates Elements RPC connection and signer interface
13126 /// 3. Authenticates with AMP API
13127 /// 4. Creates a burn request via the AMP API
13128 /// 5. Waits for transaction propagation and checks for lost outputs
13129 /// 6. Verifies required UTXOs are available
13130 /// 7. Verifies sufficient balance exists
13131 /// 8. Calls the Elements node's `destroyamount` RPC method
13132 /// 9. Waits for blockchain confirmations (2 confirmations minimum)
13133 /// 10. Retrieves transaction data and change information
13134 /// 11. Confirms the burn with the AMP API
13135 ///
13136 /// # Arguments
13137 /// * `asset_uuid` - The UUID of the asset to burn (must be valid UUID format)
13138 /// * `amount_to_burn` - The amount to burn (in satoshis for the asset)
13139 /// * `node_rpc` - `ElementsRpc` client for blockchain operations
13140 /// * `wallet_name` - Name of the Elements wallet containing the asset to burn
13141 /// * `signer` - Signer implementation for future support (currently not used, node RPC signs)
13142 ///
13143 /// # Returns
13144 /// Returns `Ok(())` if the burn completes successfully, or an `AmpError` if:
13145 /// - Input validation fails (invalid UUID format, invalid amount, etc.)
13146 /// - `ElementsRpc` connection cannot be established
13147 /// - Signer interface is not available
13148 /// - Authentication with AMP API fails
13149 /// - Burn request creation fails
13150 /// - Lost outputs are detected
13151 /// - Required UTXOs are not available
13152 /// - Insufficient balance exists
13153 /// - Burn transaction creation fails
13154 /// - Confirmation timeout occurs
13155 /// - Burn confirmation with AMP API fails
13156 ///
13157 /// # Examples
13158 /// ```no_run
13159 /// # use amp_rs::{ApiClient, ElementsRpc, AmpError};
13160 /// # use amp_rs::signer::LwkSoftwareSigner;
13161 /// # #[tokio::main]
13162 /// # async fn main() -> Result<(), AmpError> {
13163 /// let client = ApiClient::new().await?;
13164 /// let elements_rpc = ElementsRpc::from_env()?;
13165 /// let (_, signer) = LwkSoftwareSigner::generate_new()?;
13166 ///
13167 /// let asset_uuid = "550e8400-e29b-41d4-a716-446655440000";
13168 /// let amount = 1000000; // 0.01 of an asset with 8 decimals
13169 /// let wallet_name = "test_wallet";
13170 ///
13171 /// client.burn_asset(asset_uuid, amount, &elements_rpc, wallet_name, &signer).await?;
13172 /// println!("Burn completed successfully");
13173 /// # Ok(())
13174 /// # }
13175 /// ```
13176 ///
13177 /// # Related Methods
13178 /// - [`burn_request`](Self::burn_request) - Create a burn request only
13179 /// - [`burn_confirm`](Self::burn_confirm) - Confirm a burn transaction only
13180 #[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
13181 pub async fn burn_asset(
13182 &self,
13183 asset_uuid: &str,
13184 amount_to_burn: i64,
13185 node_rpc: &ElementsRpc,
13186 wallet_name: &str,
13187 signer: &dyn Signer,
13188 ) -> Result<(), AmpError> {
13189 let burn_span = tracing::info_span!(
13190 "burn_asset",
13191 asset_uuid = %asset_uuid,
13192 amount_to_burn = amount_to_burn
13193 );
13194 let _enter = burn_span.enter();
13195
13196 tracing::info!(
13197 "Starting asset burn workflow for asset: {} with amount: {}",
13198 asset_uuid,
13199 amount_to_burn
13200 );
13201
13202 // Step 1: Input validation - asset_uuid format
13203 tracing::debug!("Step 1: Validating asset UUID format");
13204 Self::validate_asset_uuid(asset_uuid).map_err(|e| {
13205 let error = AmpError::validation(format!("Invalid asset UUID: {e}"));
13206 tracing::error!("Asset UUID validation failed: {}", e);
13207 error.with_context("Step 1: Asset UUID validation")
13208 })?;
13209 tracing::debug!("Asset UUID validation passed");
13210
13211 // Step 2: Input validation - amount
13212 tracing::debug!("Step 2: Validating burn amount");
13213 if amount_to_burn <= 0 {
13214 let error = AmpError::validation("Amount to burn must be positive".to_string());
13215 tracing::error!("Amount validation failed: amount must be positive");
13216 return Err(error.with_context("Step 2: Amount validation"));
13217 }
13218 tracing::debug!("Amount validation passed");
13219
13220 // Step 3: Check ElementsRpc connection availability
13221 tracing::debug!("Step 3: Validating Elements RPC connection");
13222 self.validate_elements_rpc_connection(node_rpc)
13223 .await
13224 .map_err(|e| {
13225 let error = AmpError::rpc(format!("ElementsRpc connection validation failed: {e}"));
13226 tracing::error!("Elements RPC connection validation failed: {}", e);
13227 error.with_context("Step 3: Elements RPC connection validation")
13228 })?;
13229 tracing::debug!("Elements RPC connection validation passed");
13230
13231 // Step 4: Check signer interface availability (for future support)
13232 tracing::debug!("Step 4: Validating signer interface");
13233 self.validate_signer_interface(signer).await.map_err(|e| {
13234 let error = AmpError::validation(format!("Signer interface validation failed: {e}"));
13235 tracing::error!("Signer interface validation failed: {}", e);
13236 error.with_context("Step 4: Signer interface validation")
13237 })?;
13238 tracing::debug!("Signer interface validation passed");
13239
13240 tracing::info!("✓ All input validations completed successfully");
13241
13242 // Step 5: Authenticate with AMP API using existing TokenManager
13243 tracing::debug!("Step 5: Authenticating with AMP API");
13244 let _token = self.token_strategy.get_token().await.map_err(|e| {
13245 tracing::error!("AMP API authentication failed: {}", e);
13246 let amp_error = AmpError::Existing(e);
13247 if amp_error.is_retryable() {
13248 if let Some(instructions) = amp_error.retry_instructions() {
13249 tracing::warn!("Retry instructions: {}", instructions);
13250 }
13251 }
13252 amp_error.with_context("Step 5: AMP API authentication")
13253 })?;
13254 tracing::info!("✓ Successfully authenticated with AMP API");
13255
13256 // Step 6: Create burn request and parse response data
13257 tracing::debug!(
13258 "Step 6: Creating burn request with amount {}",
13259 amount_to_burn
13260 );
13261 let burn_response = self
13262 .burn_request(asset_uuid, amount_to_burn)
13263 .await
13264 .map_err(|e| {
13265 tracing::error!("Burn request creation failed: {}", e);
13266 if e.is_retryable() {
13267 if let Some(instructions) = e.retry_instructions() {
13268 tracing::warn!("Retry instructions: {}", instructions);
13269 }
13270 }
13271 e.with_context("Step 6: Burn request creation")
13272 })?;
13273
13274 tracing::info!(
13275 "✓ Burn request created successfully: asset_id={}, amount={}",
13276 burn_response.asset_id,
13277 burn_response.amount
13278 );
13279
13280 // Step 7: Verify Elements node status
13281 tracing::debug!("Step 7: Verifying Elements node status");
13282 let (network_info, blockchain_info) = node_rpc.get_node_status().await.map_err(|e| {
13283 tracing::error!("Elements node status verification failed: {}", e);
13284 if e.is_retryable() {
13285 if let Some(instructions) = e.retry_instructions() {
13286 tracing::warn!("Retry instructions: {}", instructions);
13287 }
13288 }
13289 e.with_context("Step 7: Elements node status verification")
13290 })?;
13291
13292 tracing::info!(
13293 "✓ Elements node verified - chain: {}, blocks: {}, connections: {}",
13294 blockchain_info.chain,
13295 blockchain_info.blocks,
13296 network_info.connections
13297 );
13298
13299 // Step 8: Wait for transaction propagation (60 seconds as per Python script)
13300 tracing::debug!("Step 8: Waiting for transaction propagation (60 seconds)");
13301 tracing::info!("Waiting 60 seconds for transaction propagation...");
13302 tokio::time::sleep(tokio::time::Duration::from_secs(60)).await;
13303 tracing::debug!("Transaction propagation wait completed");
13304
13305 // Step 9: Check for lost outputs
13306 tracing::debug!("Step 9: Checking for lost outputs");
13307 let balance_response: serde_json::Value = self
13308 .request_json(Method::GET, &["assets", asset_uuid, "balance"], None::<&()>)
13309 .await
13310 .map_err(|e| {
13311 tracing::error!("Failed to check lost outputs: {}", e);
13312 AmpError::api(format!("Balance check failed: {e}"))
13313 .with_context("Step 9: Lost outputs check")
13314 })?;
13315
13316 // Check if lost_outputs field exists and is not empty
13317 if let Some(lost_outputs) = balance_response.get("lost_outputs") {
13318 if let Some(lost_outputs_array) = lost_outputs.as_array() {
13319 if !lost_outputs_array.is_empty() {
13320 let error_msg = format!(
13321 "Lost outputs detected: {}. Transaction will not be sent.",
13322 serde_json::to_string(&lost_outputs_array).unwrap_or_default()
13323 );
13324 tracing::error!("{}", error_msg);
13325 return Err(AmpError::api(error_msg).with_context("Step 9: Lost outputs check"));
13326 }
13327 }
13328 }
13329
13330 tracing::info!("✓ No lost outputs detected");
13331
13332 // Step 10: Check UTXOs match expected UTXOs from response
13333 tracing::debug!(
13334 "Step 10: Verifying {} required UTXOs are available",
13335 burn_response.utxos.len()
13336 );
13337
13338 let available_utxos = node_rpc.list_unspent(None).await.map_err(|e| {
13339 tracing::error!("Failed to list UTXOs: {}", e);
13340 AmpError::rpc(format!("Failed to list UTXOs: {e}"))
13341 .with_context("Step 10: UTXO verification")
13342 })?;
13343
13344 // Check that all required UTXOs are available
13345 let local_utxos: std::collections::HashSet<(String, i64)> = available_utxos
13346 .iter()
13347 .map(|utxo| (utxo.txid.clone(), i64::from(utxo.vout)))
13348 .collect();
13349
13350 let mut missing_utxos = Vec::new();
13351 for required_utxo in &burn_response.utxos {
13352 if !local_utxos.contains(&(required_utxo.txid.clone(), required_utxo.vout)) {
13353 missing_utxos.push(format!("{}:{}", required_utxo.txid, required_utxo.vout));
13354 }
13355 }
13356
13357 if !missing_utxos.is_empty() {
13358 let error_msg = format!(
13359 "Missing required UTXOs: {}. Ensure the asset UTXOs are available in the wallet.",
13360 missing_utxos.join(", ")
13361 );
13362 tracing::error!("{}", error_msg);
13363 return Err(AmpError::rpc(error_msg).with_context("Step 10: UTXO verification"));
13364 }
13365
13366 tracing::info!(
13367 "✓ All {} required UTXOs are available",
13368 burn_response.utxos.len()
13369 );
13370
13371 // Step 11: Check local balance >= requested amount
13372 tracing::debug!("Step 11: Verifying sufficient balance");
13373 let balances = node_rpc.get_balance(None).await.map_err(|e| {
13374 tracing::error!("Failed to get balance: {}", e);
13375 AmpError::rpc(format!("Failed to get balance: {e}"))
13376 .with_context("Step 11: Balance verification")
13377 })?;
13378
13379 // Extract balance for the specific asset_id (getbalance returns a map)
13380 let local_amount = balances
13381 .get(&burn_response.asset_id)
13382 .and_then(serde_json::Value::as_f64)
13383 .unwrap_or(0.0);
13384 let requested_amount = burn_response.amount;
13385
13386 if local_amount < requested_amount {
13387 let error_msg = format!(
13388 "Insufficient balance: local balance ({local_amount}) is lower than requested amount ({requested_amount})"
13389 );
13390 tracing::error!("{}", error_msg);
13391 return Err(AmpError::rpc(error_msg).with_context("Step 11: Balance verification"));
13392 }
13393
13394 tracing::info!(
13395 "✓ Sufficient balance verified: local={}, requested={}",
13396 local_amount,
13397 requested_amount
13398 );
13399
13400 // Step 12: Call Elements node's destroyamount RPC method
13401 tracing::debug!("Step 12: Calling Elements destroyamount RPC method");
13402 let txid = node_rpc
13403 .destroyamount(&burn_response.asset_id, requested_amount)
13404 .await
13405 .map_err(|e| {
13406 tracing::error!("Burn transaction creation failed: {}", e);
13407 if e.is_retryable() {
13408 if let Some(instructions) = e.retry_instructions() {
13409 tracing::warn!("Retry instructions: {}", instructions);
13410 }
13411 }
13412 e.with_context("Step 12: Burn transaction creation")
13413 })?;
13414
13415 tracing::info!("✓ Burn transaction created: txid={}", txid);
13416
13417 // Step 13: Wait for confirmations
13418 tracing::debug!("Step 13: Waiting for blockchain confirmations (minimum 2 confirmations, 10-minute timeout)");
13419 let confirmation_start = std::time::Instant::now();
13420 let _tx_detail = node_rpc
13421 .wait_for_confirmations(&txid, Some(2), Some(10))
13422 .await
13423 .map_err(|e| {
13424 let elapsed = confirmation_start.elapsed();
13425 tracing::error!(
13426 "Confirmation waiting failed after {:?}: {}",
13427 elapsed,
13428 e
13429 );
13430
13431 if let AmpError::Timeout(_) = &e {
13432 tracing::warn!(
13433 "Confirmation timeout - transaction {} may still be pending. \
13434 Use this txid to manually confirm the burn if it gets confirmed later.",
13435 txid
13436 );
13437 let timeout_error = AmpError::timeout(format!(
13438 "Confirmation timeout for txid: {txid}. Use this txid to manually confirm the burn."
13439 ));
13440 timeout_error.with_context("Step 13: Confirmation waiting")
13441 } else {
13442 if e.is_retryable() {
13443 if let Some(instructions) = e.retry_instructions() {
13444 tracing::warn!("Retry instructions: {}", instructions);
13445 }
13446 }
13447 e.with_context(format!("Step 13: Confirmation waiting for txid: {txid}"))
13448 }
13449 })?;
13450
13451 tracing::info!("✓ Transaction confirmed with at least 2 confirmations");
13452
13453 // Step 14: Get transaction data and change data
13454 tracing::debug!("Step 14: Retrieving transaction data and change information");
13455
13456 // Get transaction details (we only need txid for tx_data)
13457 let tx_data = serde_json::json!({
13458 "txid": txid
13459 });
13460
13461 // Get change_data from listunspent with blinding data filtered by asset_id and txid
13462 // We need to use list_unspent_with_blinding_data to get amountblinder and assetblinder fields
13463 // required by the AMP API
13464 let all_unspent = node_rpc
13465 .list_unspent_with_blinding_data(wallet_name)
13466 .await
13467 .map_err(|e| {
13468 tracing::error!("Failed to list unspent outputs with blinding data: {}", e);
13469 AmpError::rpc(format!(
13470 "Failed to list unspent outputs with blinding data: {e}"
13471 ))
13472 .with_context("Step 14: Change data retrieval")
13473 })?;
13474
13475 // Filter and convert to JSON values, preserving all fields including blinding data
13476 let change_data: Vec<serde_json::Value> = all_unspent
13477 .into_iter()
13478 .filter(|utxo| utxo.asset == burn_response.asset_id && utxo.txid == txid)
13479 .map(|utxo| {
13480 // Serialize the full Unspent struct to JSON to include all fields
13481 // including amountblinder and assetblinder which are required by the API
13482 serde_json::to_value(&utxo).unwrap_or_else(|e| {
13483 tracing::warn!("Failed to serialize UTXO to JSON: {}", e);
13484 // Fallback to manual construction if serialization fails
13485 serde_json::json!({
13486 "txid": utxo.txid,
13487 "vout": utxo.vout,
13488 "address": utxo.address,
13489 "amount": utxo.amount,
13490 "asset": utxo.asset,
13491 "spendable": utxo.spendable,
13492 "amountblinder": utxo.amountblinder,
13493 "assetblinder": utxo.assetblinder
13494 })
13495 })
13496 })
13497 .collect();
13498
13499 tracing::info!(
13500 "✓ Retrieved transaction data and {} change output(s) for txid {}",
13501 change_data.len(),
13502 txid
13503 );
13504
13505 // Step 15: Confirm burn with AMP API
13506 tracing::debug!("Step 15: Confirming burn with AMP API");
13507
13508 self.burn_confirm(asset_uuid, tx_data, change_data)
13509 .await
13510 .map_err(|e| {
13511 tracing::error!("Burn confirmation failed: {}", e);
13512
13513 // For confirmation failures, always provide retry instructions with txid
13514 let confirmation_error = AmpError::api(format!(
13515 "Failed to confirm burn: {e}. \
13516 IMPORTANT: Transaction {txid} was successful on blockchain. \
13517 Use this txid to manually retry confirmation."
13518 ));
13519
13520 if e.is_retryable() {
13521 if let Some(instructions) = e.retry_instructions() {
13522 tracing::warn!("Retry instructions: {}", instructions);
13523 }
13524 }
13525
13526 confirmation_error.with_context("Step 15: Burn confirmation")
13527 })?;
13528
13529 tracing::info!(
13530 "🎉 Asset burn completed successfully for asset: {} with transaction: {}",
13531 asset_uuid,
13532 txid
13533 );
13534
13535 Ok(())
13536 }
13537
13538 /// Validates the asset UUID format
13539 ///
13540 /// Ensures the asset UUID follows the standard UUID format (8-4-4-4-12 hexadecimal digits)
13541 ///
13542 /// # Arguments
13543 /// * `asset_uuid` - The asset UUID string to validate
13544 ///
13545 /// # Returns
13546 /// Returns `Ok(())` if valid, or an error describing the validation failure
13547 ///
13548 /// # Errors
13549 /// - Empty or whitespace-only UUID
13550 /// - Invalid UUID format (not matching standard UUID pattern)
13551 /// - UUID contains invalid characters
13552 fn validate_asset_uuid(asset_uuid: &str) -> Result<(), String> {
13553 if asset_uuid.trim().is_empty() {
13554 return Err("Asset UUID cannot be empty".to_string());
13555 }
13556
13557 // Basic UUID format validation (8-4-4-4-12 pattern)
13558 // Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
13559 let parts: Vec<&str> = asset_uuid.split('-').collect();
13560 if parts.len() != 5 {
13561 return Err(format!(
13562 "Asset UUID '{asset_uuid}' does not have 5 parts separated by hyphens"
13563 ));
13564 }
13565
13566 // Check each part has the correct length and contains only hex characters
13567 let expected_lengths = [8, 4, 4, 4, 12];
13568 for (i, (part, &expected_len)) in parts.iter().zip(expected_lengths.iter()).enumerate() {
13569 if part.len() != expected_len {
13570 return Err(format!(
13571 "Asset UUID part {} has length {} but expected {}",
13572 i + 1,
13573 part.len(),
13574 expected_len
13575 ));
13576 }
13577
13578 // Check if all characters are valid hexadecimal
13579 if !part.chars().all(|c| c.is_ascii_hexdigit()) {
13580 return Err(format!(
13581 "Asset UUID part {} contains non-hexadecimal characters: '{}'",
13582 i + 1,
13583 part
13584 ));
13585 }
13586 }
13587
13588 tracing::debug!("Asset UUID validation passed: {}", asset_uuid);
13589 Ok(())
13590 }
13591
13592 /// Validates the assignments data structure
13593 ///
13594 /// Ensures assignments vector is not empty and each assignment has valid data
13595 ///
13596 /// # Arguments
13597 /// * `assignments` - Vector of assignments to validate
13598 ///
13599 /// # Returns
13600 /// Returns `Ok(())` if valid, or an error describing the validation failure
13601 ///
13602 /// # Errors
13603 /// - Empty assignments vector
13604 /// - Assignment with empty `user_id`
13605 /// - Assignment with empty address
13606 /// - Assignment with non-positive amount
13607 /// - Assignment with invalid address format
13608 #[allow(clippy::cognitive_complexity)]
13609 fn validate_assignments(assignments: &[AssetDistributionAssignment]) -> Result<(), String> {
13610 tracing::debug!("Validating {} assignments", assignments.len());
13611
13612 if assignments.is_empty() {
13613 tracing::error!("Assignments validation failed: empty assignments vector");
13614 return Err("Assignments vector cannot be empty".to_string());
13615 }
13616
13617 let mut total_amount = 0.0;
13618 let mut unique_addresses = std::collections::HashSet::new();
13619 let mut unique_users = std::collections::HashSet::new();
13620
13621 for (index, assignment) in assignments.iter().enumerate() {
13622 tracing::trace!(
13623 "Validating assignment {}: user_id={}, address={}, amount={}",
13624 index,
13625 assignment.user_id,
13626 assignment.address,
13627 assignment.amount
13628 );
13629
13630 // Validate user_id
13631 if assignment.user_id.trim().is_empty() {
13632 tracing::error!("Assignment {} validation failed: empty user_id", index);
13633 return Err(format!("Assignment {index} has empty user_id"));
13634 }
13635
13636 // Validate address
13637 if assignment.address.trim().is_empty() {
13638 tracing::error!("Assignment {} validation failed: empty address", index);
13639 return Err(format!("Assignment {index} has empty address"));
13640 }
13641
13642 // Basic address format validation (should start with appropriate prefix for Liquid)
13643 if !assignment.address.starts_with("lq")
13644 && !assignment.address.starts_with("vj")
13645 && !assignment.address.starts_with("VJ")
13646 && !assignment.address.starts_with("VT")
13647 {
13648 tracing::error!(
13649 "Assignment {} validation failed: invalid address format '{}' (should start with 'lq', 'vj', 'VJ', or 'VT')",
13650 index, assignment.address
13651 );
13652 return Err(format!(
13653 "Assignment {} has invalid address format: '{}' (should start with 'lq', 'vj', 'VJ', or 'VT')",
13654 index, assignment.address
13655 ));
13656 }
13657
13658 // Validate amount
13659 if assignment.amount <= 0.0 {
13660 tracing::error!(
13661 "Assignment {} validation failed: non-positive amount {}",
13662 index,
13663 assignment.amount
13664 );
13665 return Err(format!(
13666 "Assignment {} has non-positive amount: {}",
13667 index, assignment.amount
13668 ));
13669 }
13670
13671 // Check for reasonable amount limits (prevent overflow issues)
13672 if assignment.amount > 21_000_000.0 {
13673 tracing::error!(
13674 "Assignment {} validation failed: unreasonably large amount {} (max: 21,000,000)",
13675 index, assignment.amount
13676 );
13677 return Err(format!(
13678 "Assignment {} has unreasonably large amount: {} (max: 21,000,000)",
13679 index, assignment.amount
13680 ));
13681 }
13682
13683 // Check for precision issues (more than 8 decimal places)
13684 let amount_str = format!("{:.8}", assignment.amount);
13685 if amount_str.len() > 20 {
13686 // Reasonable length check
13687 tracing::warn!(
13688 "Assignment {} has high precision amount: {} - may cause precision issues",
13689 index,
13690 assignment.amount
13691 );
13692 }
13693
13694 // Track duplicates for warnings
13695 if !unique_addresses.insert(&assignment.address) {
13696 tracing::warn!(
13697 "Assignment {} uses duplicate address: {} (this may be intentional)",
13698 index,
13699 assignment.address
13700 );
13701 }
13702
13703 if !unique_users.insert(&assignment.user_id) {
13704 tracing::warn!(
13705 "Assignment {} uses duplicate user_id: {} (this may be intentional)",
13706 index,
13707 assignment.user_id
13708 );
13709 }
13710
13711 total_amount += assignment.amount;
13712 }
13713
13714 tracing::debug!(
13715 "Assignments validation passed - {} assignments, total amount: {}, unique addresses: {}, unique users: {}",
13716 assignments.len(),
13717 total_amount,
13718 unique_addresses.len(),
13719 unique_users.len()
13720 );
13721
13722 if total_amount > 100_000_000.0 {
13723 tracing::warn!(
13724 "Total distribution amount is very large: {} - ensure this is intentional",
13725 total_amount
13726 );
13727 }
13728
13729 Ok(())
13730 }
13731
13732 /// Validates `ElementsRpc` connection availability
13733 ///
13734 /// Attempts to connect to the Elements node and verify basic functionality
13735 ///
13736 /// # Arguments
13737 /// * `node_rpc` - `ElementsRpc` client to validate
13738 ///
13739 /// # Returns
13740 /// Returns `Ok(())` if connection is valid, or an error describing the failure
13741 ///
13742 /// # Errors
13743 /// - Cannot connect to Elements node
13744 /// - Node is not synchronized
13745 /// - Node version is incompatible
13746 /// - RPC authentication fails
13747 #[allow(clippy::cognitive_complexity)]
13748 async fn validate_elements_rpc_connection(&self, node_rpc: &ElementsRpc) -> Result<(), String> {
13749 tracing::debug!("Validating Elements RPC connection");
13750
13751 // Test basic connectivity by getting network info
13752 tracing::trace!("Testing Elements RPC connectivity with getnetworkinfo");
13753 let network_info = node_rpc.get_network_info().await.map_err(|e| {
13754 tracing::error!("Failed to get network info from Elements node: {}", e);
13755 format!("Failed to get network info: {e}")
13756 })?;
13757
13758 tracing::debug!(
13759 "Network info retrieved - version: {}, connections: {}, network_active: {}",
13760 network_info.version,
13761 network_info.connections,
13762 network_info.networkactive
13763 );
13764
13765 // Check if network is active
13766 if !network_info.networkactive {
13767 tracing::error!("Elements node network is not active");
13768 return Err("Elements node network is not active".to_string());
13769 }
13770
13771 // Verify we have active connections (for non-regtest environments)
13772 if network_info.connections == 0 {
13773 tracing::warn!("Elements node has no peer connections (may be regtest environment)");
13774 } else {
13775 tracing::debug!(
13776 "Elements node has {} peer connections",
13777 network_info.connections
13778 );
13779 }
13780
13781 // Test blockchain info to ensure node is operational
13782 tracing::trace!("Testing Elements RPC with getblockchaininfo");
13783 let blockchain_info = node_rpc.get_blockchain_info().await.map_err(|e| {
13784 tracing::error!("Failed to get blockchain info from Elements node: {}", e);
13785 format!("Failed to get blockchain info: {e}")
13786 })?;
13787
13788 let sync_progress = blockchain_info.verificationprogress.unwrap_or(1.0) * 100.0;
13789 tracing::debug!(
13790 "Blockchain info retrieved - chain: {}, blocks: {}, sync_progress: {:.2}%",
13791 blockchain_info.chain,
13792 blockchain_info.blocks,
13793 sync_progress
13794 );
13795
13796 // Check if node is still in initial block download
13797 if blockchain_info.initialblockdownload.unwrap_or(false) {
13798 tracing::error!(
13799 "Elements node is still in initial block download (sync progress: {:.2}%)",
13800 sync_progress
13801 );
13802 return Err(format!(
13803 "Elements node is still in initial block download (sync progress: {sync_progress:.2}%)"
13804 ));
13805 }
13806
13807 // Check sync progress
13808 if blockchain_info.verificationprogress.unwrap_or(1.0) < 0.99 {
13809 tracing::warn!(
13810 "Elements node may not be fully synced (sync progress: {:.2}%)",
13811 sync_progress
13812 );
13813 }
13814
13815 // Check for warnings
13816 if !network_info.warnings.is_empty() {
13817 tracing::warn!("Elements node network warnings: {}", network_info.warnings);
13818 }
13819
13820 if let Some(warnings) = &blockchain_info.warnings {
13821 if !warnings.is_empty() {
13822 tracing::warn!("Elements node blockchain warnings: {}", warnings);
13823 }
13824 }
13825
13826 tracing::debug!(
13827 "ElementsRpc connection validation passed - chain: {}, blocks: {}, connections: {}, sync: {:.2}%",
13828 blockchain_info.chain,
13829 blockchain_info.blocks,
13830 network_info.connections,
13831 sync_progress
13832 );
13833
13834 Ok(())
13835 }
13836
13837 /// Validates signer interface availability
13838 ///
13839 /// Tests the signer interface with a dummy transaction to ensure it's functional
13840 ///
13841 /// # Arguments
13842 /// * `signer` - Signer implementation to validate
13843 ///
13844 /// # Returns
13845 /// Returns `Ok(())` if signer is functional, or an error describing the failure
13846 ///
13847 /// # Errors
13848 /// - Signer interface is not responsive
13849 /// - Signer fails basic functionality test
13850 #[allow(clippy::cognitive_complexity)]
13851 async fn validate_signer_interface(&self, signer: &dyn Signer) -> Result<(), String> {
13852 tracing::debug!("Validating signer interface");
13853
13854 // Test signer with a minimal dummy transaction hex
13855 // This is a minimal Elements transaction structure that should parse but not be valid for signing
13856 let dummy_tx = "0200000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
13857
13858 tracing::trace!("Testing signer interface with dummy transaction");
13859
13860 // Attempt to sign the dummy transaction - we expect this to fail with a specific error
13861 // but the signer should be responsive and not panic
13862 let validation_start = std::time::Instant::now();
13863 match signer.sign_transaction(dummy_tx).await {
13864 Ok(signed_tx) => {
13865 // Unexpected success with dummy transaction - this might indicate an issue
13866 tracing::warn!(
13867 "Signer unexpectedly succeeded with dummy transaction (returned: {} chars)",
13868 signed_tx.len()
13869 );
13870 tracing::debug!("Signer validation passed despite unexpected success");
13871 }
13872 Err(SignerError::InvalidTransaction(msg)) => {
13873 // Expected error - signer is working and correctly identified invalid transaction
13874 tracing::debug!(
13875 "Signer interface validation passed - correctly rejected dummy transaction: {}",
13876 msg
13877 );
13878 }
13879 Err(SignerError::HexParse(msg)) => {
13880 // Also acceptable - signer is working and correctly identified parsing issue
13881 tracing::debug!(
13882 "Signer interface validation passed - correctly identified hex parsing issue: {}",
13883 msg
13884 );
13885 }
13886 Err(SignerError::Lwk(msg)) => {
13887 // LWK-specific errors might be acceptable depending on the message
13888 if msg.contains("invalid") || msg.contains("parse") || msg.contains("decode") {
13889 tracing::debug!(
13890 "Signer interface validation passed - LWK correctly identified invalid transaction: {}",
13891 msg
13892 );
13893 } else {
13894 tracing::error!("Signer interface test failed with LWK error: {}", msg);
13895 return Err(format!(
13896 "Signer interface test failed with LWK error: {msg}"
13897 ));
13898 }
13899 }
13900 Err(e) => {
13901 // Other errors might indicate signer interface issues
13902 tracing::error!("Signer interface test failed: {}", e);
13903 return Err(format!("Signer interface test failed: {e}"));
13904 }
13905 }
13906
13907 let validation_duration = validation_start.elapsed();
13908 tracing::debug!(
13909 "Signer interface validation completed in {:?}",
13910 validation_duration
13911 );
13912
13913 // Warn if signer is very slow (might indicate performance issues)
13914 if validation_duration > std::time::Duration::from_secs(5) {
13915 tracing::warn!(
13916 "Signer interface validation took {:?} - this may indicate performance issues",
13917 validation_duration
13918 );
13919 }
13920
13921 Ok(())
13922 }
13923}
13924
13925fn get_amp_api_base_url() -> Result<Url, Error> {
13926 let url_str = env::var("AMP_API_BASE_URL")
13927 .unwrap_or_else(|_| "https://amp-test.blockstream.com/api".to_string());
13928 Url::parse(&url_str).map_err(Error::from)
13929}
13930
13931/// Creates a token strategy based on automatic environment detection
13932///
13933/// This function detects the current environment and creates the appropriate strategy:
13934/// - Mock strategy for mock environments (isolated, no persistence)
13935/// - Live strategy for live environments (full token management)
13936///
13937/// # Arguments
13938/// * `mock_token` - Optional token to use for mock environments
13939///
13940/// # Errors
13941/// Returns an error if strategy creation fails
13942pub async fn create_auto_token_strategy(
13943 mock_token: Option<String>,
13944) -> Result<Box<dyn TokenStrategy>, Error> {
13945 TokenEnvironment::create_auto_strategy(mock_token).await
13946}
13947
13948/// Creates a mock token strategy with the specified token
13949///
13950/// # Arguments
13951/// * `token` - The mock token to use
13952#[must_use]
13953pub fn create_mock_token_strategy(token: String) -> Box<dyn TokenStrategy> {
13954 Box::new(MockTokenStrategy::new(token))
13955}
13956
13957/// Creates a live token strategy with default configuration
13958///
13959/// # Errors
13960/// Returns an error if the `TokenManager` cannot be initialized
13961pub async fn create_live_token_strategy() -> Result<Box<dyn TokenStrategy>, Error> {
13962 let strategy = LiveTokenStrategy::new().await?;
13963 Ok(Box::new(strategy))
13964}
13965
13966/// Creates a token strategy for the specified environment
13967///
13968/// # Arguments
13969/// * `environment` - The target environment
13970/// * `mock_token` - Optional token to use for mock environments
13971///
13972/// # Errors
13973/// Returns an error if strategy creation fails
13974pub async fn create_token_strategy_for_environment(
13975 environment: TokenEnvironment,
13976 mock_token: Option<String>,
13977) -> Result<Box<dyn TokenStrategy>, Error> {
13978 environment.create_strategy(mock_token).await
13979}
13980
13981#[cfg(test)]
13982mod tests {
13983 use super::*;
13984 use crate::signer::LwkSoftwareSigner;
13985 use tokio;
13986
13987 #[tokio::test]
13988 async fn test_mock_token_strategy_basic_functionality() {
13989 let mock_token = "mock_token_12_345".to_string();
13990 let strategy = MockTokenStrategy::new(mock_token.clone());
13991
13992 // Test get_token returns the mock token
13993 let result = strategy.get_token().await;
13994 assert!(result.is_ok());
13995 assert_eq!(result.unwrap(), mock_token);
13996
13997 // Test strategy type identification
13998 assert_eq!(strategy.strategy_type(), "mock");
13999
14000 // Test persistence is disabled
14001 assert!(!strategy.should_persist());
14002
14003 // Test clear_token is a no-op (should not fail)
14004 let clear_result = strategy.clear_token().await;
14005 assert!(clear_result.is_ok());
14006
14007 // Verify token is still available after clear (since it's a no-op for mock)
14008 let token_after_clear = strategy.get_token().await;
14009 assert!(token_after_clear.is_ok());
14010 assert_eq!(token_after_clear.unwrap(), mock_token);
14011 }
14012
14013 #[tokio::test]
14014 async fn test_mock_token_strategy_isolation() {
14015 let token1 = "token_instance_1".to_string();
14016 let token2 = "token_instance_2".to_string();
14017
14018 let strategy1 = MockTokenStrategy::new(token1.clone());
14019 let strategy2 = MockTokenStrategy::new(token2.clone());
14020
14021 // Test that different instances are isolated
14022 let result1 = strategy1.get_token().await.unwrap();
14023 let result2 = strategy2.get_token().await.unwrap();
14024
14025 assert_eq!(result1, token1);
14026 assert_eq!(result2, token2);
14027 assert_ne!(result1, result2);
14028
14029 // Test that operations on one don't affect the other
14030 let _ = strategy1.clear_token().await;
14031 let result2_after_clear = strategy2.get_token().await.unwrap();
14032 assert_eq!(result2_after_clear, token2);
14033 }
14034
14035 #[tokio::test]
14036 async fn test_live_token_strategy_creation() {
14037 // Test creating a live strategy with global instance
14038 let strategy_result = LiveTokenStrategy::new().await;
14039 assert!(strategy_result.is_ok());
14040
14041 let strategy = strategy_result.unwrap();
14042 assert_eq!(strategy.strategy_type(), "live");
14043 assert!(strategy.should_persist());
14044 }
14045
14046 #[tokio::test]
14047 async fn test_live_token_strategy_with_custom_manager() {
14048 // Create a custom token manager for testing
14049 let config = RetryConfig::for_tests();
14050 let base_url = Url::parse("http://localhost:8080").unwrap();
14051 let mock_token = "test_live_token".to_string();
14052
14053 let token_manager =
14054 Arc::new(TokenManager::with_mock_token(config, base_url, mock_token.clone()).unwrap());
14055
14056 let strategy = LiveTokenStrategy::with_token_manager(token_manager);
14057
14058 // Test strategy properties
14059 assert_eq!(strategy.strategy_type(), "live");
14060 assert!(strategy.should_persist());
14061
14062 // Test token retrieval
14063 let token_result = strategy.get_token().await;
14064 assert!(token_result.is_ok());
14065 assert_eq!(token_result.unwrap(), mock_token);
14066 }
14067
14068 #[tokio::test]
14069 async fn test_live_token_strategy_clear_token() {
14070 // Create a live strategy with a mock token manager
14071 let config = RetryConfig::for_tests();
14072 let base_url = Url::parse("http://localhost:8080").unwrap();
14073 let mock_token = "test_clear_token".to_string();
14074
14075 let token_manager =
14076 Arc::new(TokenManager::with_mock_token(config, base_url, mock_token.clone()).unwrap());
14077
14078 let strategy = LiveTokenStrategy::with_token_manager(token_manager);
14079
14080 // Verify token is available initially
14081 let initial_token = strategy.get_token().await;
14082 assert!(initial_token.is_ok());
14083 assert_eq!(initial_token.unwrap(), mock_token);
14084
14085 // Clear the token
14086 let clear_result = strategy.clear_token().await;
14087 assert!(clear_result.is_ok());
14088
14089 // Note: After clearing, the TokenManager would try to obtain a new token
14090 // In a real scenario, this would fail without proper credentials
14091 // But our mock token manager will still return the same token
14092 }
14093
14094 #[tokio::test]
14095 async fn test_strategy_type_identification() {
14096 let mock_strategy = MockTokenStrategy::new("test_token".to_string());
14097 let live_strategy = LiveTokenStrategy::new().await.unwrap();
14098
14099 // Test that we can identify strategy types for debugging
14100 assert_eq!(mock_strategy.strategy_type(), "mock");
14101 assert_eq!(live_strategy.strategy_type(), "live");
14102
14103 // Test persistence settings
14104 assert!(!mock_strategy.should_persist());
14105 assert!(live_strategy.should_persist());
14106 }
14107
14108 #[tokio::test]
14109 async fn test_strategy_debug_formatting() {
14110 let mock_strategy = MockTokenStrategy::new("debug_test_token".to_string());
14111 let debug_output = format!("{mock_strategy:?}");
14112
14113 // Verify debug output contains expected information
14114 assert!(debug_output.contains("MockTokenStrategy"));
14115 assert!(debug_output.contains("debug_test_token"));
14116 }
14117
14118 // Environment Detection Tests
14119
14120 #[test]
14121 fn test_token_environment_detect_live_via_amp_tests() {
14122 // Set up environment for live test detection
14123 env::set_var("AMP_TESTS", "live");
14124 env::set_var("AMP_USERNAME", "real_user");
14125 env::set_var("AMP_PASSWORD", "real_pass");
14126 env::remove_var("AMP_API_BASE_URL");
14127
14128 let environment = TokenEnvironment::detect();
14129 assert_eq!(environment, TokenEnvironment::Live);
14130
14131 // Clean up
14132 env::remove_var("AMP_TESTS");
14133 env::remove_var("AMP_USERNAME");
14134 env::remove_var("AMP_PASSWORD");
14135 }
14136
14137 #[test]
14138 fn test_token_environment_detect_mock_via_credentials() {
14139 // Set up environment for mock detection via username
14140 env::remove_var("AMP_TESTS");
14141 env::set_var("AMP_USERNAME", "mock_user");
14142 env::set_var("AMP_PASSWORD", "real_pass");
14143 env::remove_var("AMP_API_BASE_URL");
14144
14145 let environment = TokenEnvironment::detect();
14146 assert_eq!(environment, TokenEnvironment::Mock);
14147
14148 // Test mock detection via password
14149 env::set_var("AMP_USERNAME", "real_user");
14150 env::set_var("AMP_PASSWORD", "mock_pass");
14151
14152 let environment = TokenEnvironment::detect();
14153 assert_eq!(environment, TokenEnvironment::Mock);
14154
14155 // Clean up
14156 env::remove_var("AMP_USERNAME");
14157 env::remove_var("AMP_PASSWORD");
14158 }
14159
14160 #[test]
14161 fn test_token_environment_detect_mock_via_base_url() {
14162 // Set up environment for mock detection via localhost URL
14163 env::remove_var("AMP_TESTS");
14164 env::set_var("AMP_USERNAME", "real_user");
14165 env::set_var("AMP_PASSWORD", "real_pass");
14166 env::set_var("AMP_API_BASE_URL", "http://localhost:8080/api");
14167
14168 let environment = TokenEnvironment::detect();
14169 assert_eq!(environment, TokenEnvironment::Mock);
14170
14171 // Test with 127.0.0.1
14172 env::set_var("AMP_API_BASE_URL", "http://127.0.0.1:3000/api");
14173 let environment = TokenEnvironment::detect();
14174 assert_eq!(environment, TokenEnvironment::Mock);
14175
14176 // Test with mock in URL
14177 env::set_var("AMP_API_BASE_URL", "http://mock-server.example.com/api");
14178 let environment = TokenEnvironment::detect();
14179 assert_eq!(environment, TokenEnvironment::Mock);
14180
14181 // Clean up
14182 env::remove_var("AMP_USERNAME");
14183 env::remove_var("AMP_PASSWORD");
14184 env::remove_var("AMP_API_BASE_URL");
14185 }
14186
14187 #[test]
14188 fn test_token_environment_detect_live_via_real_credentials() {
14189 // Set up environment for live detection via real credentials
14190 env::remove_var("AMP_TESTS");
14191 env::set_var("AMP_USERNAME", "real_user");
14192 env::set_var("AMP_PASSWORD", "real_pass");
14193 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
14194
14195 let environment = TokenEnvironment::detect();
14196 assert_eq!(environment, TokenEnvironment::Live);
14197
14198 // Clean up
14199 env::remove_var("AMP_USERNAME");
14200 env::remove_var("AMP_PASSWORD");
14201 env::remove_var("AMP_API_BASE_URL");
14202 }
14203
14204 #[test]
14205 fn test_token_environment_detect_mock_fallback() {
14206 // Set up environment with no credentials (fallback to mock)
14207 env::remove_var("AMP_TESTS");
14208 env::remove_var("AMP_USERNAME");
14209 env::remove_var("AMP_PASSWORD");
14210 env::remove_var("AMP_API_BASE_URL");
14211
14212 let environment = TokenEnvironment::detect();
14213 assert_eq!(environment, TokenEnvironment::Mock);
14214 }
14215
14216 #[test]
14217 fn test_has_mock_credentials() {
14218 // Test mock username detection
14219 assert!(TokenEnvironment::has_mock_credentials(
14220 "mock_user",
14221 "real_pass",
14222 ""
14223 ));
14224 assert!(TokenEnvironment::has_mock_credentials(
14225 "Mock_User",
14226 "real_pass",
14227 ""
14228 ));
14229 assert!(TokenEnvironment::has_mock_credentials(
14230 "user_mock",
14231 "real_pass",
14232 ""
14233 ));
14234
14235 // Test mock password detection
14236 assert!(TokenEnvironment::has_mock_credentials(
14237 "real_user",
14238 "mock_pass",
14239 ""
14240 ));
14241 assert!(TokenEnvironment::has_mock_credentials(
14242 "real_user",
14243 "Mock_Pass",
14244 ""
14245 ));
14246 assert!(TokenEnvironment::has_mock_credentials(
14247 "real_user",
14248 "pass_mock",
14249 ""
14250 ));
14251
14252 // Test mock URL detection
14253 assert!(TokenEnvironment::has_mock_credentials(
14254 "real_user",
14255 "real_pass",
14256 "http://localhost:8080"
14257 ));
14258 assert!(TokenEnvironment::has_mock_credentials(
14259 "real_user",
14260 "real_pass",
14261 "http://127.0.0.1:3000"
14262 ));
14263 assert!(TokenEnvironment::has_mock_credentials(
14264 "real_user",
14265 "real_pass",
14266 "http://mock-server.com"
14267 ));
14268 assert!(TokenEnvironment::has_mock_credentials(
14269 "real_user",
14270 "real_pass",
14271 "http://Mock-Server.com"
14272 ));
14273
14274 // Test non-mock credentials
14275 assert!(!TokenEnvironment::has_mock_credentials(
14276 "real_user",
14277 "real_pass",
14278 "https://amp-test.blockstream.com"
14279 ));
14280 assert!(!TokenEnvironment::has_mock_credentials("", "", ""));
14281 }
14282
14283 #[test]
14284 fn test_token_environment_should_persist_tokens() {
14285 assert!(!TokenEnvironment::Mock.should_persist_tokens());
14286 assert!(TokenEnvironment::Live.should_persist_tokens());
14287
14288 // Auto should delegate to detect()
14289 env::set_var("AMP_TESTS", "live");
14290 assert!(TokenEnvironment::Auto.should_persist_tokens());
14291
14292 env::set_var("AMP_USERNAME", "mock_user");
14293 env::set_var("AMP_PASSWORD", "some_password");
14294 env::remove_var("AMP_TESTS");
14295 env::remove_var("AMP_API_BASE_URL");
14296 assert!(!TokenEnvironment::Auto.should_persist_tokens());
14297
14298 // Clean up
14299 env::remove_var("AMP_USERNAME");
14300 env::remove_var("AMP_PASSWORD");
14301 }
14302
14303 #[test]
14304 fn test_token_environment_is_mock_and_is_live() {
14305 assert!(TokenEnvironment::Mock.is_mock());
14306 assert!(!TokenEnvironment::Mock.is_live());
14307
14308 assert!(!TokenEnvironment::Live.is_mock());
14309 assert!(TokenEnvironment::Live.is_live());
14310
14311 // Auto should delegate to detect()
14312 env::set_var("AMP_USERNAME", "mock_user");
14313 env::set_var("AMP_PASSWORD", "some_password");
14314 env::remove_var("AMP_TESTS");
14315 env::remove_var("AMP_API_BASE_URL");
14316 assert!(TokenEnvironment::Auto.is_mock());
14317 assert!(!TokenEnvironment::Auto.is_live());
14318
14319 env::set_var("AMP_TESTS", "live");
14320 assert!(!TokenEnvironment::Auto.is_mock());
14321 assert!(TokenEnvironment::Auto.is_live());
14322
14323 // Clean up
14324 env::remove_var("AMP_USERNAME");
14325 env::remove_var("AMP_PASSWORD");
14326 env::remove_var("AMP_TESTS");
14327 }
14328
14329 #[tokio::test]
14330 async fn test_token_environment_create_strategy_mock() {
14331 let mock_token = "test_mock_token".to_string();
14332 let strategy = TokenEnvironment::Mock
14333 .create_strategy(Some(mock_token.clone()))
14334 .await
14335 .unwrap();
14336
14337 assert_eq!(strategy.strategy_type(), "mock");
14338 assert!(!strategy.should_persist());
14339
14340 let token = strategy.get_token().await.unwrap();
14341 assert_eq!(token, mock_token);
14342 }
14343
14344 #[tokio::test]
14345 async fn test_token_environment_create_strategy_live() {
14346 let strategy = TokenEnvironment::Live.create_strategy(None).await.unwrap();
14347
14348 assert_eq!(strategy.strategy_type(), "live");
14349 assert!(strategy.should_persist());
14350 }
14351
14352 #[tokio::test]
14353 async fn test_token_environment_create_auto_strategy() {
14354 // Test with mock environment - need both username and password for proper detection
14355 env::set_var("AMP_USERNAME", "mock_user");
14356 env::set_var("AMP_PASSWORD", "some_password");
14357 env::remove_var("AMP_TESTS");
14358 env::remove_var("AMP_API_BASE_URL");
14359
14360 let mock_token = "auto_mock_token".to_string();
14361 let strategy = TokenEnvironment::create_auto_strategy(Some(mock_token.clone()))
14362 .await
14363 .unwrap();
14364
14365 assert_eq!(strategy.strategy_type(), "mock");
14366 let token = strategy.get_token().await.unwrap();
14367 assert_eq!(token, mock_token);
14368
14369 // Clean up
14370 env::remove_var("AMP_USERNAME");
14371 env::remove_var("AMP_PASSWORD");
14372 }
14373
14374 #[tokio::test]
14375 async fn test_mock_token_strategy_factory_methods() {
14376 // Test with_default_token
14377 let strategy = MockTokenStrategy::with_default_token();
14378 assert_eq!(strategy.strategy_type(), "mock");
14379 let token = strategy.get_token().await.unwrap();
14380 assert_eq!(token, "mock_token_default");
14381
14382 // Test for_test
14383 let strategy = MockTokenStrategy::for_test("my_test");
14384 let token = strategy.get_token().await.unwrap();
14385 assert_eq!(token, "mock_token_my_test");
14386 }
14387
14388 #[tokio::test]
14389 async fn test_live_token_strategy_factory_methods() {
14390 // Test for_testing
14391 let strategy = LiveTokenStrategy::for_testing().await.unwrap();
14392 assert_eq!(strategy.strategy_type(), "live");
14393 assert!(strategy.should_persist());
14394 }
14395
14396 #[tokio::test]
14397 async fn test_standalone_factory_functions() {
14398 // Test create_mock_token_strategy
14399 let mock_token = "standalone_mock".to_string();
14400 let strategy = create_mock_token_strategy(mock_token.clone());
14401 assert_eq!(strategy.strategy_type(), "mock");
14402 let token = strategy.get_token().await.unwrap();
14403 assert_eq!(token, mock_token);
14404
14405 // Test create_live_token_strategy
14406 let strategy = create_live_token_strategy().await.unwrap();
14407 assert_eq!(strategy.strategy_type(), "live");
14408
14409 // Test create_auto_token_strategy with mock environment
14410 env::set_var("AMP_USERNAME", "mock_user");
14411 env::set_var("AMP_PASSWORD", "some_password");
14412 env::remove_var("AMP_TESTS");
14413 env::remove_var("AMP_API_BASE_URL");
14414
14415 let auto_mock_token = "auto_standalone_mock".to_string();
14416 let strategy = create_auto_token_strategy(Some(auto_mock_token.clone()))
14417 .await
14418 .unwrap();
14419 assert_eq!(strategy.strategy_type(), "mock");
14420 let token = strategy.get_token().await.unwrap();
14421 assert_eq!(token, auto_mock_token);
14422
14423 // Test create_token_strategy_for_environment
14424 let env_mock_token = "env_mock".to_string();
14425 let strategy = create_token_strategy_for_environment(
14426 TokenEnvironment::Mock,
14427 Some(env_mock_token.clone()),
14428 )
14429 .await
14430 .unwrap();
14431 assert_eq!(strategy.strategy_type(), "mock");
14432 let token = strategy.get_token().await.unwrap();
14433 assert_eq!(token, env_mock_token);
14434
14435 // Clean up
14436 env::remove_var("AMP_USERNAME");
14437 env::remove_var("AMP_PASSWORD");
14438 }
14439
14440 #[test]
14441 fn test_environment_detection_with_various_credential_combinations() {
14442 // Test case 1: AMP_TESTS=live overrides everything
14443 env::set_var("AMP_TESTS", "live");
14444 env::set_var("AMP_USERNAME", "mock_user");
14445 env::set_var("AMP_PASSWORD", "mock_pass");
14446 env::set_var("AMP_API_BASE_URL", "http://localhost:8080");
14447 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Live);
14448
14449 // Test case 2: Mock username with real password and URL
14450 env::remove_var("AMP_TESTS");
14451 env::set_var("AMP_USERNAME", "mock_user");
14452 env::set_var("AMP_PASSWORD", "real_password");
14453 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
14454 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
14455
14456 // Test case 3: Real username with mock password
14457 env::set_var("AMP_USERNAME", "real_user");
14458 env::set_var("AMP_PASSWORD", "mock_password");
14459 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
14460 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
14461
14462 // Test case 4: Real credentials with localhost URL
14463 env::set_var("AMP_USERNAME", "real_user");
14464 env::set_var("AMP_PASSWORD", "real_password");
14465 env::set_var("AMP_API_BASE_URL", "http://localhost:3000/api");
14466 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
14467
14468 // Test case 5: All real credentials
14469 env::set_var("AMP_USERNAME", "real_user");
14470 env::set_var("AMP_PASSWORD", "real_password");
14471 env::set_var("AMP_API_BASE_URL", "https://amp-test.blockstream.com/api");
14472 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Live);
14473
14474 // Test case 6: Empty credentials
14475 env::remove_var("AMP_USERNAME");
14476 env::remove_var("AMP_PASSWORD");
14477 env::remove_var("AMP_API_BASE_URL");
14478 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
14479
14480 // Test case 7: Only username set
14481 env::set_var("AMP_USERNAME", "real_user");
14482 env::remove_var("AMP_PASSWORD");
14483 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
14484
14485 // Test case 8: Only password set
14486 env::remove_var("AMP_USERNAME");
14487 env::set_var("AMP_PASSWORD", "real_password");
14488 assert_eq!(TokenEnvironment::detect(), TokenEnvironment::Mock);
14489
14490 // Clean up all environment variables
14491 env::remove_var("AMP_TESTS");
14492 env::remove_var("AMP_USERNAME");
14493 env::remove_var("AMP_PASSWORD");
14494 env::remove_var("AMP_API_BASE_URL");
14495 }
14496
14497 #[tokio::test]
14498 async fn test_distribute_asset_input_validation() {
14499 // Create a mock client for testing
14500 let client = ApiClient::with_mock_token(
14501 reqwest::Url::parse("http://localhost:8080/api").unwrap(),
14502 "test_token".to_string(),
14503 )
14504 .unwrap();
14505
14506 // Test invalid asset UUID
14507 let assignments = vec![AssetDistributionAssignment {
14508 user_id: "user123".to_string(),
14509 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
14510 amount: 100.0,
14511 }];
14512
14513 // Create a mock ElementsRpc (this will fail connection validation, but that's expected)
14514 let elements_rpc = ElementsRpc::new(
14515 "http://localhost:18884".to_string(),
14516 "user".to_string(),
14517 "pass".to_string(),
14518 );
14519
14520 // Create a mock signer
14521 let (_, signer) = LwkSoftwareSigner::generate_new().unwrap();
14522
14523 // Test with invalid UUID format
14524 let result = client
14525 .distribute_asset(
14526 "invalid-uuid",
14527 assignments.clone(),
14528 &elements_rpc,
14529 "test_wallet",
14530 &signer,
14531 )
14532 .await;
14533
14534 assert!(result.is_err());
14535 if let Err(AmpError::Validation(msg)) = result {
14536 assert!(msg.contains("Invalid asset UUID"));
14537 } else {
14538 panic!("Expected validation error for invalid UUID");
14539 }
14540
14541 // Test with empty assignments
14542 let result = client
14543 .distribute_asset(
14544 "550e8400-e29b-41d4-a716-446655440000",
14545 vec![],
14546 &elements_rpc,
14547 "test_wallet",
14548 &signer,
14549 )
14550 .await;
14551
14552 assert!(result.is_err());
14553 if let Err(AmpError::Validation(msg)) = result {
14554 assert!(msg.contains("Invalid assignments"));
14555 } else {
14556 panic!("Expected validation error for empty assignments");
14557 }
14558 }
14559
14560 #[test]
14561 fn test_validate_asset_uuid() {
14562 let _client = ApiClient::with_mock_token(
14563 reqwest::Url::parse("http://localhost:8080/api").unwrap(),
14564 "test_token".to_string(),
14565 )
14566 .unwrap();
14567
14568 // Valid UUID
14569 assert!(ApiClient::validate_asset_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
14570
14571 // Invalid UUIDs
14572 assert!(ApiClient::validate_asset_uuid("").is_err());
14573 assert!(ApiClient::validate_asset_uuid("invalid").is_err());
14574 assert!(ApiClient::validate_asset_uuid("550e8400-e29b-41d4-a716").is_err()); // Too short
14575 assert!(
14576 ApiClient::validate_asset_uuid("550e8400-e29b-41d4-a716-446655440000-extra").is_err()
14577 ); // Too long
14578 assert!(ApiClient::validate_asset_uuid("550e8400xe29bx41d4xa716x446655440000").is_err()); // Wrong separators
14579 assert!(ApiClient::validate_asset_uuid("550e8400-e29g-41d4-a716-446655440000").is_err());
14580 // Invalid hex char
14581 }
14582
14583 #[test]
14584 fn test_validate_assignments() {
14585 let _client = ApiClient::with_mock_token(
14586 reqwest::Url::parse("http://localhost:8080/api").unwrap(),
14587 "test_token".to_string(),
14588 )
14589 .unwrap();
14590
14591 // Valid assignments
14592 let valid_assignments = vec![AssetDistributionAssignment {
14593 user_id: "user123".to_string(),
14594 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
14595 amount: 100.0,
14596 }];
14597 assert!(ApiClient::validate_assignments(&valid_assignments).is_ok());
14598
14599 // Empty assignments
14600 assert!(ApiClient::validate_assignments(&[]).is_err());
14601
14602 // Assignment with empty user_id
14603 let invalid_assignments = vec![AssetDistributionAssignment {
14604 user_id: "".to_string(),
14605 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
14606 amount: 100.0,
14607 }];
14608 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
14609
14610 // Assignment with empty address
14611 let invalid_assignments = vec![AssetDistributionAssignment {
14612 user_id: "user123".to_string(),
14613 address: "".to_string(),
14614 amount: 100.0,
14615 }];
14616 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
14617
14618 // Assignment with invalid address format
14619 let invalid_assignments = vec![AssetDistributionAssignment {
14620 user_id: "user123".to_string(),
14621 address: "invalid_address".to_string(),
14622 amount: 100.0,
14623 }];
14624 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
14625
14626 // Assignment with non-positive amount
14627 let invalid_assignments = vec![AssetDistributionAssignment {
14628 user_id: "user123".to_string(),
14629 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
14630 amount: 0.0,
14631 }];
14632 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
14633
14634 // Assignment with unreasonably large amount
14635 let invalid_assignments = vec![AssetDistributionAssignment {
14636 user_id: "user123".to_string(),
14637 address: "lq1qq2xvpcvfup5j8zscjq05u2wxxjcyewk7979f9lq".to_string(),
14638 amount: 25_000_000.0,
14639 }];
14640 assert!(ApiClient::validate_assignments(&invalid_assignments).is_err());
14641 }
14642
14643 #[test]
14644 fn test_enhanced_error_handling_and_logging() {
14645 // Test AmpError creation and context enhancement
14646 let api_error = AmpError::api("Distribution creation failed");
14647 let contextual_error = api_error.with_context("Step 6: Distribution creation");
14648
14649 match contextual_error {
14650 AmpError::Api(msg) => {
14651 assert!(msg.contains("Step 6: Distribution creation"));
14652 assert!(msg.contains("Distribution creation failed"));
14653 }
14654 _ => panic!("Expected Api error variant"),
14655 }
14656
14657 // Test retry instructions for different error types
14658 let rpc_error = AmpError::rpc("Connection failed");
14659 assert!(rpc_error.is_retryable());
14660 assert!(rpc_error.retry_instructions().is_some());
14661 assert!(rpc_error
14662 .retry_instructions()
14663 .unwrap()
14664 .contains("Elements node"));
14665
14666 let validation_error = AmpError::validation("Invalid UUID");
14667 assert!(!validation_error.is_retryable());
14668 assert!(validation_error.retry_instructions().is_none());
14669
14670 let timeout_error = AmpError::timeout("Confirmation timeout for txid abc123");
14671 assert!(!timeout_error.is_retryable());
14672 let instructions = timeout_error.retry_instructions();
14673 assert!(instructions.is_some());
14674 assert!(instructions.unwrap().contains("transaction ID"));
14675
14676 // Test error helper methods
14677 let signer_error =
14678 AmpError::Signer(crate::signer::SignerError::Lwk("Test error".to_string()));
14679 assert!(!signer_error.is_retryable());
14680 assert!(signer_error.retry_instructions().is_none());
14681
14682 // Test serialization error
14683 let json_error = serde_json::from_str::<serde_json::Value>("invalid json").unwrap_err();
14684 let serialization_error = AmpError::from(json_error);
14685 assert!(matches!(serialization_error, AmpError::Serialization(_)));
14686 assert!(!serialization_error.is_retryable());
14687 }
14688}