use crate::error::{BitcoinError, Result};
use base64::{Engine as _, engine::general_purpose};
use bitcoin::{Address, Network, Psbt};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::process::Command;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HardwareWalletType {
Ledger,
Trezor,
Coldcard,
BitBox,
KeepKey,
}
impl HardwareWalletType {
pub fn as_str(&self) -> &'static str {
match self {
HardwareWalletType::Ledger => "ledger",
HardwareWalletType::Trezor => "trezor",
HardwareWalletType::Coldcard => "coldcard",
HardwareWalletType::BitBox => "bitbox",
HardwareWalletType::KeepKey => "keepkey",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HardwareDevice {
pub device_type: HardwareWalletType,
pub model: String,
pub fingerprint: String,
pub path: String,
pub needs_pin: bool,
pub needs_passphrase: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HwiConfig {
pub hwi_path: PathBuf,
pub network: Network,
pub timeout: u64,
}
impl Default for HwiConfig {
fn default() -> Self {
Self {
hwi_path: PathBuf::from("hwi"),
network: Network::Bitcoin,
timeout: 30,
}
}
}
pub struct HardwareWalletManager {
config: HwiConfig,
}
impl HardwareWalletManager {
pub fn new(config: HwiConfig) -> Self {
Self { config }
}
pub fn with_default_config(network: Network) -> Self {
Self {
config: HwiConfig {
network,
..Default::default()
},
}
}
pub fn enumerate_devices(&self) -> Result<Vec<HardwareDevice>> {
let output = Command::new(&self.config.hwi_path)
.arg("enumerate")
.output()
.map_err(|e| BitcoinError::InvalidAddress(format!("Failed to execute HWI: {}", e)))?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(BitcoinError::InvalidAddress(format!(
"HWI enumerate failed: {}",
error
)));
}
let devices: Vec<HardwareDevice> = serde_json::from_slice(&output.stdout).map_err(|e| {
BitcoinError::InvalidAddress(format!("Failed to parse HWI output: {}", e))
})?;
tracing::info!(count = devices.len(), "Enumerated hardware devices");
Ok(devices)
}
pub fn get_xpub(&self, device: &HardwareDevice, derivation_path: &str) -> Result<String> {
let output = Command::new(&self.config.hwi_path)
.arg("--device-path")
.arg(&device.path)
.arg("getxpub")
.arg(derivation_path)
.output()
.map_err(|e| {
BitcoinError::InvalidAddress(format!("Failed to execute HWI getxpub: {}", e))
})?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(BitcoinError::InvalidAddress(format!(
"HWI getxpub failed: {}",
error
)));
}
let response: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| {
BitcoinError::InvalidAddress(format!("Failed to parse HWI output: {}", e))
})?;
let xpub = response["xpub"]
.as_str()
.ok_or_else(|| BitcoinError::InvalidAddress("No xpub in HWI response".to_string()))?
.to_string();
tracing::info!(
device_type = ?device.device_type,
path = derivation_path,
"Retrieved xpub from hardware wallet"
);
Ok(xpub)
}
pub fn get_address(
&self,
device: &HardwareDevice,
derivation_path: &str,
show_on_device: bool,
) -> Result<Address> {
let mut cmd = Command::new(&self.config.hwi_path);
cmd.arg("--device-path")
.arg(&device.path)
.arg("displayaddress");
if !show_on_device {
cmd.arg("--no-display");
}
cmd.arg(derivation_path);
let output = cmd.output().map_err(|e| {
BitcoinError::InvalidAddress(format!("Failed to execute HWI displayaddress: {}", e))
})?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(BitcoinError::InvalidAddress(format!(
"HWI displayaddress failed: {}",
error
)));
}
let response: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| {
BitcoinError::InvalidAddress(format!("Failed to parse HWI output: {}", e))
})?;
let address_str = response["address"].as_str().ok_or_else(|| {
BitcoinError::InvalidAddress("No address in HWI response".to_string())
})?;
let address = Address::from_str(address_str)?
.require_network(self.config.network)
.map_err(|e| BitcoinError::InvalidAddress(format!("Network mismatch: {}", e)))?;
tracing::info!(
device_type = ?device.device_type,
path = derivation_path,
show_on_device = show_on_device,
"Retrieved address from hardware wallet"
);
Ok(address)
}
pub fn sign_psbt(&self, device: &HardwareDevice, psbt: &Psbt) -> Result<Psbt> {
let psbt_base64 = general_purpose::STANDARD.encode(psbt.serialize());
let output = Command::new(&self.config.hwi_path)
.arg("--device-path")
.arg(&device.path)
.arg("signtx")
.arg(psbt_base64)
.output()
.map_err(|e| {
BitcoinError::InvalidAddress(format!("Failed to execute HWI signtx: {}", e))
})?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(BitcoinError::InvalidAddress(format!(
"HWI signtx failed: {}",
error
)));
}
let response: serde_json::Value = serde_json::from_slice(&output.stdout).map_err(|e| {
BitcoinError::InvalidAddress(format!("Failed to parse HWI output: {}", e))
})?;
let signed_psbt_str = response["psbt"]
.as_str()
.ok_or_else(|| BitcoinError::InvalidAddress("No PSBT in HWI response".to_string()))?;
let psbt_bytes = general_purpose::STANDARD
.decode(signed_psbt_str)
.map_err(|e| {
BitcoinError::InvalidAddress(format!("Failed to decode PSBT base64: {}", e))
})?;
let signed_psbt = Psbt::deserialize(&psbt_bytes)?;
tracing::info!(
device_type = ?device.device_type,
"Signed PSBT with hardware wallet"
);
Ok(signed_psbt)
}
pub fn prompt_pin(&self, device: &HardwareDevice, pin: &str) -> Result<()> {
let output = Command::new(&self.config.hwi_path)
.arg("--device-path")
.arg(&device.path)
.arg("promptpin")
.output()
.map_err(|e| {
BitcoinError::InvalidAddress(format!("Failed to execute HWI promptpin: {}", e))
})?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(BitcoinError::InvalidAddress(format!(
"HWI promptpin failed: {}",
error
)));
}
let output = Command::new(&self.config.hwi_path)
.arg("--device-path")
.arg(&device.path)
.arg("sendpin")
.arg(pin)
.output()
.map_err(|e| {
BitcoinError::InvalidAddress(format!("Failed to execute HWI sendpin: {}", e))
})?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(BitcoinError::InvalidAddress(format!(
"HWI sendpin failed: {}",
error
)));
}
tracing::info!(
device_type = ?device.device_type,
"PIN entered successfully"
);
Ok(())
}
}
pub struct AirGappedSigner {
pub exchange_dir: PathBuf,
}
impl AirGappedSigner {
pub fn new(exchange_dir: PathBuf) -> Self {
Self { exchange_dir }
}
pub fn export_psbt(&self, psbt: &Psbt, filename: &str) -> Result<PathBuf> {
let path = self.exchange_dir.join(filename);
let psbt_base64 = general_purpose::STANDARD.encode(psbt.serialize());
std::fs::write(&path, psbt_base64).map_err(|e| {
BitcoinError::InvalidAddress(format!("Failed to write PSBT file: {}", e))
})?;
tracing::info!(path = ?path, "Exported PSBT for air-gapped signing");
Ok(path)
}
pub fn import_signed_psbt(&self, filename: &str) -> Result<Psbt> {
let path = self.exchange_dir.join(filename);
let psbt_base64 = std::fs::read_to_string(&path).map_err(|e| {
BitcoinError::InvalidAddress(format!("Failed to read PSBT file: {}", e))
})?;
let psbt_bytes = general_purpose::STANDARD
.decode(&psbt_base64)
.map_err(|e| {
BitcoinError::InvalidAddress(format!("Failed to decode PSBT base64: {}", e))
})?;
let psbt = Psbt::deserialize(&psbt_bytes)?;
tracing::info!(path = ?path, "Imported signed PSBT from air-gapped device");
Ok(psbt)
}
pub fn export_as_qr(&self, psbt: &Psbt) -> Result<String> {
let psbt_base64 = general_purpose::STANDARD.encode(psbt.serialize());
tracing::info!("Exported PSBT as QR code data");
Ok(psbt_base64)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hardware_wallet_type() {
assert_eq!(HardwareWalletType::Ledger.as_str(), "ledger");
assert_eq!(HardwareWalletType::Trezor.as_str(), "trezor");
assert_eq!(HardwareWalletType::Coldcard.as_str(), "coldcard");
assert_eq!(HardwareWalletType::BitBox.as_str(), "bitbox");
assert_eq!(HardwareWalletType::KeepKey.as_str(), "keepkey");
}
#[test]
fn test_hwi_config_default() {
let config = HwiConfig::default();
assert_eq!(config.network, Network::Bitcoin);
assert_eq!(config.timeout, 30);
}
#[test]
fn test_hardware_wallet_manager_creation() {
let manager = HardwareWalletManager::with_default_config(Network::Testnet);
assert_eq!(manager.config.network, Network::Testnet);
}
#[test]
fn test_air_gapped_signer_creation() {
let signer = AirGappedSigner::new(PathBuf::from("/tmp/psbt"));
assert_eq!(signer.exchange_dir, PathBuf::from("/tmp/psbt"));
}
}