diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 3e89fe6..a770d6a 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -42,6 +42,7 @@ use spaces_wallet::{ nostr::{NostrEvent, NostrTag}, Listing, }; +use hex; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -98,12 +99,18 @@ enum Commands { ExportWallet { // Destination path to export json file path: PathBuf, + /// Include hex-encoded private key in export + #[arg(long, short = 'x')] + hex_secret: bool, }, /// Import a wallet #[command(name = "importwallet")] ImportWallet { // Wallet json file to import path: PathBuf, + /// Use hex-encoded private key from json to generate taproot descriptor + #[arg(long, short = 'x')] + hex_secret: bool, }, /// Export a wallet #[command(name = "getwalletinfo")] @@ -536,6 +543,26 @@ fn normalize_space(space: &str) -> String { } } +fn generate_taproot_descriptor_from_hex(hex_secret: &str, _network: ExtendedNetwork) -> Result { + // Parse hex secret + let secret_bytes = hex::decode(hex_secret) + .map_err(|e| ClientError::Custom(format!("Invalid hex secret: {}", e)))?; + + if secret_bytes.len() != 32 { + return Err(ClientError::Custom("Hex secret must be 32 bytes (64 hex characters)".to_string())); + } + + // For now, create a simple descriptor that indicates this is a hex-based import + // The actual xprv conversion would need to be done properly in the wallet creation + // This is a placeholder that will need to be handled by the wallet creation process + let descriptor = format!( + "tr([hex:{}]/86'/0'/0'/0/*)", + hex_secret + ); + + Ok(descriptor) +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let (cli, args) = SpaceCli::configure().await?; @@ -629,14 +656,32 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client Commands::LoadWallet => { cli.client.wallet_load(&cli.wallet).await?; } - Commands::ImportWallet { path } => { + Commands::ImportWallet { path, hex_secret } => { let content = fs::read_to_string(path).map_err(|e| ClientError::Custom(e.to_string()))?; - let wallet: WalletExport = serde_json::from_str(&content)?; + let mut wallet: WalletExport = serde_json::from_str(&content)?; + + // If hex_secret is requested, generate taproot descriptor from hex secret + if hex_secret { + if let Some(hex_secret_value) = &wallet.hex_secret { + // For now, we'll create a simple descriptor format + // The actual conversion to xprv should be done in the wallet creation process + let taproot_descriptor = format!("tr([hex:{}]/86'/0'/0'/0/*)", hex_secret_value); + wallet.descriptor = Some(taproot_descriptor); + } else { + return Err(ClientError::Custom("hex-secret option specified but no hex_secret found in wallet json".to_string())); + } + } else { + // If not using hex_secret, ensure descriptor is present + if wallet.descriptor.is_none() { + return Err(ClientError::Custom("descriptor field is required when not using hex-secret option".to_string())); + } + } + cli.client.wallet_import(wallet).await?; } - Commands::ExportWallet { path } => { - let result = cli.client.wallet_export(&cli.wallet).await?; + Commands::ExportWallet { path, hex_secret } => { + let result = cli.client.wallet_export(&cli.wallet, hex_secret).await?; let content = serde_json::to_string_pretty(&result).expect("result"); fs::write(path, content).map_err(|e| { ClientError::Custom(format!("Could not save to path: {}", e.to_string())) diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 447a583..f9a125c 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -37,6 +37,7 @@ use spaces_protocol::{ validate::TxChangeSet, Bytes, Covenant, FullSpaceOut, SpaceOut, }; +use hex; use spaces_wallet::{ bdk_wallet as bdk, bdk_wallet::template::Bip86, bitcoin::hashes::Hash as BitcoinHash, export::WalletExport, nostr::NostrEvent, Balance, DoubleUtxo, Listing, SpacesWallet, @@ -240,7 +241,7 @@ pub trait Rpc { -> Result; #[method(name = "walletexport")] - async fn wallet_export(&self, name: &str) -> Result; + async fn wallet_export(&self, name: &str, hex_secret: bool) -> Result; #[method(name = "walletcreate")] async fn wallet_create(&self, name: &str) -> Result; @@ -479,7 +480,7 @@ pub struct WalletLoadRequest { const RPC_WALLET_NOT_LOADED: i32 = -18; impl WalletManager { - pub async fn import_wallet(&self, wallet: WalletExport) -> anyhow::Result<()> { + pub async fn import_wallet(&self, mut wallet: WalletExport) -> anyhow::Result<()> { let wallet_path = self.data_dir.join(&wallet.label); if wallet_path.exists() { return Err(anyhow!(format!( @@ -488,6 +489,37 @@ impl WalletManager { ))); } + // If this is a hex-based descriptor, convert it to proper xprv format before storing + if let Some(descriptor) = &wallet.descriptor { + if descriptor.starts_with("tr([hex:") { + if let Some(hex_secret) = &wallet.hex_secret { + let (network, _) = self.fallback_network(); + let xpriv = Self::xpriv_from_hex_secret(network, hex_secret)?; + let (external_desc, internal_desc) = Self::default_descriptors(xpriv); + + // Create a temporary wallet to get the descriptor strings + let temp_wallet = bdk::Wallet::create(external_desc, internal_desc) + .network(network) + .create_wallet_no_persist()?; + + // Get proper xprv-based descriptor string + let proper_descriptor = temp_wallet + .public_descriptor(KeychainKind::External) + .to_string_with_secret( + &temp_wallet + .get_signers(KeychainKind::External) + .as_key_map(temp_wallet.secp_ctx()), + ); + let proper_descriptor = Self::remove_checksum(proper_descriptor); + + // Update the wallet to store the proper xprv descriptor + wallet.descriptor = Some(proper_descriptor); + } else { + return Err(anyhow!("Hex-based descriptor found but no hex_secret in wallet")); + } + } + } + fs::create_dir_all(&wallet_path)?; let wallet_export_path = wallet_path.join("wallet.json"); let mut file = fs::File::create(wallet_export_path)?; @@ -497,13 +529,30 @@ impl WalletManager { Ok(()) } - pub async fn export_wallet(&self, name: &str) -> anyhow::Result { + pub async fn export_wallet(&self, name: &str, hex_secret: bool) -> anyhow::Result { let wallet_dir = self.data_dir.join(name); if !wallet_dir.exists() { return Err(anyhow!("Wallet does not exist")); } let wallet = fs::read_to_string(wallet_dir.join("wallet.json"))?; - let export: WalletExport = serde_json::from_str(&wallet)?; + let mut export: WalletExport = serde_json::from_str(&wallet)?; + + // If hex_secret is requested, use the stored hex_secret if available, + // otherwise extract it from the xprv descriptor + if hex_secret { + if export.hex_secret.is_none() { + // Only extract from descriptor if no hex_secret was stored + if let Some(descriptor) = &export.descriptor { + if let Some(hex_secret_value) = self.extract_hex_secret_from_descriptor(descriptor)? { + export.hex_secret = Some(hex_secret_value); + } + } + } + } else { + // If hex_secret is not requested, remove it from the export + export.hex_secret = None; + } + Ok(export) } @@ -558,7 +607,7 @@ impl WalletManager { .network(network) .create_wallet_no_persist()?; let export = - WalletExport::export_wallet(&tmp, &name, start_block.height).map_err(|e| anyhow!(e))?; + WalletExport::export_wallet(&tmp, &name, start_block.height, false).map_err(|e| anyhow!(e))?; Ok(export) } @@ -632,7 +681,7 @@ impl WalletManager { let file = fs::File::open(wallet_dir.join("wallet.json"))?; let (network, genesis_hash) = self.fallback_network(); - let export: WalletExport = serde_json::from_reader(file)?; + let mut export: WalletExport = serde_json::from_reader(file)?; let wallet_config = WalletConfig { start_block: export.blockheight, @@ -640,11 +689,16 @@ impl WalletManager { name: name.to_string(), network, genesis_hash, - space_descriptors: WalletDescriptors { - external: export.descriptor(), - internal: export - .change_descriptor() - .expect("expected a change descriptor"), + space_descriptors: { + let external_descriptor = export.descriptor().expect("expected a descriptor"); + let internal_descriptor = export.change_descriptor().expect("expected a change descriptor"); + + // At this point, the descriptor should already be in proper xprv format + // since import_wallet converts hex-based descriptors before storing + WalletDescriptors { + external: external_descriptor, + internal: internal_descriptor, + } }, }; @@ -682,12 +736,95 @@ impl WalletManager { Ok(xkey.into_xprv(network).expect("xpriv")) } + fn xpriv_from_hex_secret(network: Network, hex_secret: &str) -> anyhow::Result { + use spaces_protocol::bitcoin::bip32::{Xpriv, ChainCode, ChildNumber}; + use spaces_protocol::bitcoin::key::Secp256k1; + use spaces_protocol::bitcoin::secp256k1::SecretKey; + + // Parse hex secret + let secret_bytes = hex::decode(hex_secret) + .map_err(|e| anyhow!("Invalid hex secret: {}", e))?; + + if secret_bytes.len() != 32 { + return Err(anyhow!("Hex secret must be 32 bytes (64 hex characters)")); + } + + // Convert to SecretKey + let secret_key = SecretKey::from_slice(&secret_bytes) + .map_err(|e| anyhow!("Invalid secret key: {}", e))?; + + // Create Xpriv directly from the private key bytes + // We need to create a proper xprv structure with the exact private key + let secp = Secp256k1::new(); + + // Create the xprv by manually constructing it with the exact private key + // This ensures the private key bytes are preserved exactly + let xpriv = Xpriv { + network: network.into(), + depth: 0, + parent_fingerprint: Default::default(), + child_number: ChildNumber::from_normal_idx(0)?, + chain_code: [0u8; 32].into(), // Use zero chain code for direct private key + private_key: secret_key, + }; + + Ok(xpriv) + } + + fn descriptor_from_hex_secret(network: Network, hex_secret: &str) -> anyhow::Result { + // Parse hex secret + let secret_bytes = hex::decode(hex_secret) + .map_err(|e| anyhow!("Invalid hex secret: {}", e))?; + + if secret_bytes.len() != 32 { + return Err(anyhow!("Hex secret must be 32 bytes (64 hex characters)")); + } + + // For now, create a simple descriptor that can be processed by the wallet creation + // The actual xprv conversion will need to be handled in the wallet creation process + // This is a placeholder format that indicates this is a hex-based import + Ok(format!("tr([hex:{}]/86'/0'/0'/0/*)", hex_secret)) + } + fn default_descriptors(x: Xpriv) -> (Bip86, Bip86) { ( Bip86(x, KeychainKind::External), Bip86(x, KeychainKind::Internal), ) } + + fn remove_checksum(s: String) -> String { + s.split_once('#').map(|(a, _)| String::from(a)).unwrap_or(s) + } + + fn extract_hex_secret_from_descriptor(&self, descriptor: &str) -> anyhow::Result> { + // Parse the descriptor to extract the xprv + // The descriptor format is typically: tr([xprv...]/path)... + // We need to extract the xprv part and convert it to hex + + // Find the xprv in the descriptor + if let Some(start) = descriptor.find("xprv") { + // Find the end of the xprv (before the next '/' or ')') + let end = descriptor[start..] + .find(|c| c == '/' || c == ')') + .map(|i| start + i) + .unwrap_or(descriptor.len()); + + let xprv_str = &descriptor[start..end]; + + // Parse the xprv + if let Ok(xprv) = Xpriv::from_str(xprv_str) { + return Ok(Some(hex::encode(xprv.private_key.secret_bytes()))); + } + } + + Ok(None) + } + + pub fn create_taproot_descriptor_from_hex(&self, hex_secret: &str) -> anyhow::Result { + let (network, _) = self.fallback_network(); + Self::descriptor_from_hex_secret(network, hex_secret) + } } impl RpcServerImpl { @@ -926,9 +1063,9 @@ impl RpcServer for RpcServerImpl { .await .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } - async fn wallet_export(&self, name: &str) -> Result { + async fn wallet_export(&self, name: &str, hex_secret: bool) -> Result { self.wallet_manager - .export_wallet(name) + .export_wallet(name, hex_secret) .await .map_err(|error| { ErrorObjectOwned::owned(RPC_WALLET_NOT_LOADED, error.to_string(), None::) diff --git a/wallet/src/export.rs b/wallet/src/export.rs index cb308a4..75ec6bb 100644 --- a/wallet/src/export.rs +++ b/wallet/src/export.rs @@ -12,18 +12,25 @@ use core::{fmt, str::FromStr}; use bdk_wallet::{KeychainKind, Wallet}; +use bdk_wallet::keys::DescriptorSecretKey; use serde::{Deserialize, Serialize}; +use hex; /// Structure that contains the export of a wallet /// /// For a usage example see [this module](crate::wallet::export)'s documentation. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct WalletExport { - pub descriptor: String, + /// Wallet descriptor (optional when using hex-secret import) + #[serde(skip_serializing_if = "Option::is_none")] + pub descriptor: Option, /// Earliest block to rescan when looking for the wallet's transactions pub blockheight: u32, /// Arbitrary label for the wallet pub label: String, + /// Hex-encoded private key (optional) + #[serde(skip_serializing_if = "Option::is_none")] + pub hex_secret: Option, } impl fmt::Display for WalletExport { @@ -60,6 +67,7 @@ impl WalletExport { wallet: &Wallet, label: &str, blockheight: u32, + include_hex_secret: bool, ) -> Result { let descriptor = wallet .public_descriptor(KeychainKind::External) @@ -70,10 +78,29 @@ impl WalletExport { ); let descriptor = remove_checksum(descriptor); + // Extract hex secret if requested + let hex_secret = if include_hex_secret { + match wallet + .get_signers(KeychainKind::External) + .signers() + .iter() + .filter_map(|s| s.descriptor_secret_key()) + .next() + { + Some(DescriptorSecretKey::XPrv(xprv)) => { + Some(hex::encode(xprv.xkey.private_key.secret_bytes())) + } + _ => None, + } + } else { + None + }; + let export = WalletExport { - descriptor, + descriptor: Some(descriptor), label: label.into(), blockheight, + hex_secret, }; let change_descriptor = { @@ -95,16 +122,19 @@ impl WalletExport { } /// Return the external descriptor - pub fn descriptor(&self) -> String { + pub fn descriptor(&self) -> Option { self.descriptor.clone() } /// Return the internal descriptor, if present pub fn change_descriptor(&self) -> Option { - let replaced = self.descriptor.replace("/0/*", "/1/*"); - - if replaced != self.descriptor { - Some(replaced) + if let Some(descriptor) = &self.descriptor { + let replaced = descriptor.replace("/0/*", "/1/*"); + if replaced != *descriptor { + Some(replaced) + } else { + None + } } else { None } diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 82ea6b2..5e1eb43 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -16,6 +16,7 @@ use bdk_wallet::{ }; use bdk_wallet::chain::keychain_txout::KeychainTxOutIndex; use bincode::config; +use hex; use bitcoin::{ absolute::{Height, LockTime}, bip32::ChildNumber, @@ -1230,6 +1231,25 @@ impl SpacesWallet { pub fn peek_address(&self, keychain_kind: KeychainKind, index: u32) -> AddressInfo { self.internal.peek_address(keychain_kind, index) } + + pub fn extract_hex_secret(&self) -> anyhow::Result { + let secret = match self + .internal + .get_signers(KeychainKind::External) + .signers() + .iter() + .filter_map(|s| s.descriptor_secret_key()) + .next() + { + None => return Err(anyhow::anyhow!("No secret key found in signer")), + Some(secret) => secret, + }; + let descriptor_x_key = match secret { + DescriptorSecretKey::XPrv(xprv) => xprv, + _ => return Err(anyhow::anyhow!("No xprv found")), + }; + Ok(hex::encode(descriptor_x_key.xkey.private_key.secret_bytes())) + } } #[derive(Debug)]