diff --git a/ddk/src/error.rs b/ddk/src/error.rs index 5025915..845d3de 100644 --- a/ddk/src/error.rs +++ b/ddk/src/error.rs @@ -207,6 +207,20 @@ pub enum WalletError { InvalidDerivationIndex, #[error("Invalid secret key")] InvalidSecretKey, + #[error( + "DESCRIPTOR MISMATCH DETECTED\n\n\ + {keychain}.\n\n\ + Expected descriptor:\n{expected}\n\n\ + Stored descriptor:\n{stored}\n\n\ + The wallet's stored descriptor doesn't match the descriptor\n\ + derived from the current seed. Please verify you're using the correct seed\n\ + or reset the wallet data if needed, but verify your wallet backups before resetting." + )] + DescriptorMismatch { + keychain: String, + expected: String, + stored: String, + }, } /// Converts a generic error to a DLC manager storage error diff --git a/ddk/src/wallet/mod.rs b/ddk/src/wallet/mod.rs index 7711888..0134937 100644 --- a/ddk/src/wallet/mod.rs +++ b/ddk/src/wallet/mod.rs @@ -33,7 +33,8 @@ use bdk_chain::Balance; use bdk_wallet::coin_selection::{ BranchAndBoundCoinSelection, CoinSelectionAlgorithm, SingleRandomDraw, }; -use bdk_wallet::descriptor::IntoWalletDescriptor; +use bdk_wallet::descriptor::{Descriptor, IntoWalletDescriptor}; +use bdk_wallet::keys::DescriptorPublicKey; use bdk_wallet::AsyncWalletPersister; pub use bdk_wallet::LocalOutput; use bdk_wallet::{ @@ -206,6 +207,169 @@ pub struct DlcDevKitWallet { const MIN_FEERATE: u32 = 253; +/// Helper function to extract the checksum from a descriptor string. +fn extract_descriptor_checksum(descriptor: &str) -> String { + if let Some(hash_pos) = descriptor.rfind('#') { + let checksum = &descriptor[hash_pos + 1..]; + // Trim whitespace and take exactly 8 characters (typical checksum length) + let trimmed = checksum.trim(); + trimmed.chars().take(8).collect() + } else { + "unknown".to_string() + } +} + +/// Extracts fingerprint and derivation path from bracketed content in descriptor. +fn extract_descriptor_fingerprint_and_path(descriptor: &str) -> (String, String) { + if let Some(bracket_start) = descriptor.find('[') { + if let Some(bracket_end) = descriptor[bracket_start..].find(']') { + let content = &descriptor[bracket_start + 1..bracket_start + bracket_end]; + if let Some(slash_pos) = content.find('/') { + return ( + content[..slash_pos].to_string(), + content[slash_pos + 1..].to_string(), + ); + } + } + } + ("unknown".to_string(), "unknown".to_string()) +} + +/// Attempts to extract structured information from the error chain. +/// +/// Walks the error source chain looking for: +/// 1. Exact descriptor strings in error messages (most reliable) +/// 2. Enum variant names in Debug format (e.g., "KeychainKind::External") +/// +/// Returns a tuple of (keychain, descriptor_string) if any matching evidence is found, or None otherwise. +fn extract_structured_error_info( + error: &dyn std::error::Error, + external_descriptor_str: &str, + internal_descriptor_str: &str, +) -> Option<(&'static str, String)> { + let mut current: Option<&dyn std::error::Error> = Some(error); + + // Walk the error chain + while let Some(err) = current { + let error_debug = format!("{:?}", err); + let error_msg = err.to_string(); + + // Check for exact descriptor strings (most reliable indicator) + // This works even if BDK's error format changes + if error_msg.contains(external_descriptor_str) + || error_debug.contains(external_descriptor_str) + { + return Some(("external", external_descriptor_str.to_string())); + } + + if error_msg.contains(internal_descriptor_str) + || error_debug.contains(internal_descriptor_str) + { + return Some(("internal", internal_descriptor_str.to_string())); + } + + // Try to extract keychain from Debug format enum variants + if error_debug.contains("KeychainKind::External") { + return Some(("external", external_descriptor_str.to_string())); + } + + if error_debug.contains("KeychainKind::Internal") { + return Some(("internal", internal_descriptor_str.to_string())); + } + + // Move to next error in chain + current = err.source(); + } + + None +} + +/// Returns true if the error looks like a descriptor mismatch (heuristics-based). +fn is_descriptor_mismatch( + error: &dyn std::error::Error, + external_descriptor_str: &str, + internal_descriptor_str: &str, +) -> bool { + extract_structured_error_info(error, external_descriptor_str, internal_descriptor_str).is_some() +} + +/// Identifies descriptor mismatches in BDK errors and extracts info on which keychain failed. +fn extract_descriptor_info( + error: &dyn std::error::Error, + external_descriptor_str: &str, + internal_descriptor_str: &str, +) -> WalletError { + // Extract structured information from error chain + let (keychain, expected_descriptor) = extract_structured_error_info( + error, + external_descriptor_str, + internal_descriptor_str, + ) + .unwrap_or(("unknown", external_descriptor_str.to_string())); + + // Format expected descriptor info + let expected = format!( + " Checksum: {}", + extract_descriptor_checksum(&expected_descriptor) + ); + + // Extract stored descriptor info from error message + // Note: This requires parsing the error message string, but it's necessary + // to meet the requirement of showing expected vs stored descriptor for comparison + let error_msg = error.to_string(); + let error_debug = format!("{:?}", error); + let (stored_checksum, _stored_fingerprint, _stored_path) = + extract_stored_descriptor_info(&error_msg, &error_debug); + let stored = format!(" Checksum: {}", stored_checksum); + + // Format keychain message - indicate uncertainty if we couldn't determine which keychain + let keychain_msg = if keychain == "unknown" { + "A descriptor mismatch was detected, but the specific keychain (external/internal) could not be determined".to_string() + } else { + format!("{keychain} descriptor mismatch detected") + }; + + WalletError::DescriptorMismatch { + keychain: keychain_msg, + expected, + stored, + } +} + +/// Extracts checksum, fingerprint, and derivation path from the stored descriptor +/// in BDK error messages. +fn extract_stored_descriptor_info(error_msg: &str, error_debug: &str) -> (String, String, String) { + // Try both error message formats + for text in [error_msg, error_debug] { + if let Some(loaded_pos) = text.find("loaded ") { + let after_loaded = &text[loaded_pos + 7..]; // Skip "loaded " + + // Extract the full descriptor string (up to the comma or end) + let desc_end = after_loaded.find(',').unwrap_or(after_loaded.len()); + let descriptor_str = after_loaded[..desc_end].trim(); + + // Try to parse the descriptor using BDK's parser + if let Ok(descriptor) = descriptor_str.parse::>() { + // Get the canonical string representation (includes checksum) + let canonical_str = descriptor.to_string(); + + let checksum = extract_descriptor_checksum(&canonical_str); + let (fingerprint, path) = extract_descriptor_fingerprint_and_path(&canonical_str); + + if checksum != "unknown" || path != "unknown" { + return (checksum, fingerprint, path); + } + } + } + } + + ( + "unknown".to_string(), + "unknown".to_string(), + "unknown".to_string(), + ) +} + impl DlcDevKitWallet { /// Creates a new DlcDevKitWallet instance. /// @@ -259,7 +423,16 @@ impl DlcDevKitWallet { .check_network(network) .load_wallet_async(&mut storage) .await - .map_err(|e| WalletError::WalletPersistanceError(e.to_string()))?; + .map_err(|e| { + let external_desc_str = external_descriptor.0.to_string(); + let internal_desc_str = internal_descriptor.0.to_string(); + + if is_descriptor_mismatch(&e, &external_desc_str, &internal_desc_str) { + extract_descriptor_info(&e, &external_desc_str, &internal_desc_str) + } else { + WalletError::WalletPersistanceError(e.to_string()) + } + })?; let mut wallet = match load_wallet { Some(w) => w, diff --git a/ddk/tests/wallet.rs b/ddk/tests/wallet.rs new file mode 100644 index 0000000..7cb2083 --- /dev/null +++ b/ddk/tests/wallet.rs @@ -0,0 +1,194 @@ +mod test_util; + +use bitcoin::{key::rand::Fill, Network}; +use ddk::chain::EsploraClient; +use ddk::error::WalletError; +use ddk::logger::{LogLevel, Logger}; +use ddk::storage::memory::MemoryStorage; +use ddk::wallet::DlcDevKitWallet; +use ddk::Storage; +use std::sync::Arc; + +/// Helper function to test descriptor mismatch error across different storage backends. +/// +/// This function creates a wallet with one seed, persists it, then tries to load it +/// with a different seed. It verifies that the error message correctly shows checksums +/// and derivation paths for both expected and stored descriptors for comparison. +async fn test_descriptor_mismatch_error_with_storage(storage: Arc) { + dotenv::dotenv().ok(); + + // Setup logger + let logger = Arc::new(Logger::console( + "descriptor_mismatch_test".to_string(), + LogLevel::Info, + )); + + // Setup Esplora client + let esplora_host = std::env::var("ESPLORA_HOST").expect("ESPLORA_HOST must be set"); + let esplora = Arc::new( + EsploraClient::new(&esplora_host, Network::Regtest, logger.clone()) + .expect("Failed to create Esplora client"), + ); + + let mut seed1 = [0u8; 64]; + seed1 + .try_fill(&mut bitcoin::key::rand::thread_rng()) + .expect("Failed to generate random seed"); + + let _wallet1 = DlcDevKitWallet::new( + &seed1, + esplora.clone(), + Network::Regtest, + storage.clone(), + None, + logger.clone(), + ) + .await + .expect("Failed to create first wallet"); + + let mut seed2 = [0u8; 64]; + seed2 + .try_fill(&mut bitcoin::key::rand::thread_rng()) + .expect("Failed to generate random seed"); + + let result = DlcDevKitWallet::new( + &seed2, + esplora.clone(), + Network::Regtest, + storage.clone(), + None, + logger.clone(), + ) + .await; + + match result { + Ok(_) => panic!("Expected DescriptorMismatch error but wallet loaded successfully"), + Err(WalletError::DescriptorMismatch { + keychain, + expected, + stored, + }) => { + let error_msg = format!( + "{}", + WalletError::DescriptorMismatch { + keychain: keychain.clone(), + expected: expected.clone(), + stored: stored.clone(), + } + ); + println!("\n{}", "=".repeat(80)); + println!("{}", error_msg); + println!("{}", "=".repeat(80)); + + assert!( + keychain.contains("external") && keychain.contains("descriptor mismatch detected"), + "Should identify external keychain, got: '{}'", + keychain + ); + assert!( + !expected.is_empty(), + "Expected descriptor should not be empty" + ); + assert!(!stored.is_empty(), "Stored descriptor should not be empty"); + + assert!( + expected.contains("DerivationPath:") || expected.contains("Checksum:"), + "Expected descriptor should include DerivationPath or Checksum, got: '{}'", + expected + ); + + assert!( + expected.contains("Checksum:"), + "Expected descriptor should include Checksum, got: '{}'", + expected + ); + + assert!( + stored.contains("DerivationPath:") || stored.contains("Checksum:"), + "Stored descriptor should include DerivationPath or Checksum, got: '{}'", + stored + ); + + assert!( + stored.contains("Checksum:"), + "Stored descriptor should include Checksum, got: '{}'", + stored + ); + } + Err(e) => { + println!("\n{}", "=".repeat(70)); + println!("UNEXPECTED ERROR TYPE"); + println!("{}", "=".repeat(70)); + println!("Expected DescriptorMismatch, but got: {:?}", e); + println!("Full error Debug: {:?}", e); + println!("{}", "=".repeat(70)); + panic!("Expected DescriptorMismatch, got: {:?}", e); + } + } +} + +/// Test descriptor mismatch error with MemoryStorage backend. +#[tokio::test] +async fn descriptor_mismatch_error_memory() { + dotenv::dotenv().ok(); + let storage = Arc::new(MemoryStorage::new()) as Arc; + test_descriptor_mismatch_error_with_storage(storage).await; +} + +/// Test descriptor mismatch error with SledStorage backend. +#[cfg(feature = "sled")] +#[tokio::test] +async fn descriptor_mismatch_error_sled() { + use ddk::storage::sled::SledStorage; + use uuid; + + dotenv::dotenv().ok(); + let logger = Arc::new(Logger::console( + "descriptor_mismatch_sled_test".to_string(), + LogLevel::Info, + )); + + // Create a temporary directory for the sled database + let temp_dir = std::env::temp_dir(); + let db_path = temp_dir.join(format!("ddk_test_sled_{}", uuid::Uuid::new_v4())); + + let storage = Arc::new( + SledStorage::new(db_path.to_str().unwrap(), logger).expect("Failed to create SledStorage"), + ) as Arc; + + test_descriptor_mismatch_error_with_storage(storage).await; + + // Cleanup: remove the temporary database + if db_path.exists() { + std::fs::remove_dir_all(&db_path).ok(); + } +} + +/// Test descriptor mismatch error with PostgresStorage backend. +/// Note: Requires DATABASE_URL environment variable to be set. +#[cfg(feature = "postgres")] +#[tokio::test] +async fn descriptor_mismatch_error_postgres() { + use ddk::storage::postgres::PostgresStore; + use uuid; + + dotenv::dotenv().ok(); + let postgres_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for postgres tests"); + + let logger = Arc::new(Logger::console( + "descriptor_mismatch_postgres_test".to_string(), + LogLevel::Info, + )); + + // Create a unique wallet name for this test to avoid conflicts + let wallet_name = format!("test_wallet_{}", uuid::Uuid::new_v4()); + + let storage = Arc::new( + PostgresStore::new(&postgres_url, true, logger, wallet_name.clone()) + .await + .expect("Failed to create PostgresStore"), + ) as Arc; + + test_descriptor_mismatch_error_with_storage(storage).await; +}