Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions client/src/bin/space-cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ use spaces_wallet::{
nostr::{NostrEvent, NostrTag},
Listing,
};
use hex;

#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -536,6 +543,26 @@ fn normalize_space(space: &str) -> String {
}
}

fn generate_taproot_descriptor_from_hex(hex_secret: &str, _network: ExtendedNetwork) -> Result<String, ClientError> {
// 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?;
Expand Down Expand Up @@ -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()))
Expand Down
163 changes: 150 additions & 13 deletions client/src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -240,7 +241,7 @@ pub trait Rpc {
-> Result<WalletInfoWithProgress, ErrorObjectOwned>;

#[method(name = "walletexport")]
async fn wallet_export(&self, name: &str) -> Result<WalletExport, ErrorObjectOwned>;
async fn wallet_export(&self, name: &str, hex_secret: bool) -> Result<WalletExport, ErrorObjectOwned>;

#[method(name = "walletcreate")]
async fn wallet_create(&self, name: &str) -> Result<String, ErrorObjectOwned>;
Expand Down Expand Up @@ -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!(
Expand All @@ -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)?;
Expand All @@ -497,13 +529,30 @@ impl WalletManager {
Ok(())
}

pub async fn export_wallet(&self, name: &str) -> anyhow::Result<WalletExport> {
pub async fn export_wallet(&self, name: &str, hex_secret: bool) -> anyhow::Result<WalletExport> {
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)
}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -632,19 +681,24 @@ 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,
data_dir: wallet_dir,
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,
}
},
};

Expand Down Expand Up @@ -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<Xpriv> {
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<String> {
// 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<Xpriv>, Bip86<Xpriv>) {
(
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<Option<String>> {
// 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<String> {
let (network, _) = self.fallback_network();
Self::descriptor_from_hex_secret(network, hex_secret)
}
}

impl RpcServerImpl {
Expand Down Expand Up @@ -926,9 +1063,9 @@ impl RpcServer for RpcServerImpl {
.await
.map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::<String>))
}
async fn wallet_export(&self, name: &str) -> Result<WalletExport, ErrorObjectOwned> {
async fn wallet_export(&self, name: &str, hex_secret: bool) -> Result<WalletExport, ErrorObjectOwned> {
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::<String>)
Expand Down
Loading