use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
#[cfg(feature = "hw_wallet")]
use ledger_transport::APDUCommand;
#[cfg(feature = "hw_wallet")]
use ledger_transport_hid::{hidapi::HidApi, TransportNativeHID};
pub const AINGLE_CLA: u8 = 0xE0;
pub mod ins {
pub const GET_VERSION: u8 = 0x00;
pub const GET_PUBLIC_KEY: u8 = 0x01;
pub const SIGN_HASH: u8 = 0x02;
pub const SIGN_ENTRY: u8 = 0x03;
pub const GET_DEVICE_INFO: u8 = 0x04;
}
pub const AINGLE_COIN_TYPE: u32 = 8017;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WalletConfig {
pub timeout: Duration,
pub auto_retry: bool,
pub retry_count: u8,
pub use_ble: bool,
pub default_account: u32,
}
impl Default for WalletConfig {
fn default() -> Self {
Self {
timeout: Duration::from_secs(30),
auto_retry: true,
retry_count: 3,
use_ble: false,
default_account: 0,
}
}
}
impl WalletConfig {
pub fn with_ble() -> Self {
Self {
use_ble: true,
timeout: Duration::from_secs(60), ..Default::default()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WalletType {
LedgerNanoS,
LedgerNanoX,
LedgerNanoSPlus,
TrezorOne,
TrezorModelT,
Unknown,
}
impl WalletType {
pub fn supports_ble(&self) -> bool {
matches!(self, WalletType::LedgerNanoX)
}
pub fn name(&self) -> &'static str {
match self {
WalletType::LedgerNanoS => "Ledger Nano S",
WalletType::LedgerNanoX => "Ledger Nano X",
WalletType::LedgerNanoSPlus => "Ledger Nano S Plus",
WalletType::TrezorOne => "Trezor One",
WalletType::TrezorModelT => "Trezor Model T",
WalletType::Unknown => "Unknown Device",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WalletState {
Disconnected,
Connecting,
Connected,
AwaitingConfirmation,
Locked,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WalletInfo {
pub wallet_type: WalletType,
pub firmware_version: String,
pub app_version: Option<String>,
pub has_aingle_app: bool,
pub serial: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DerivationPath {
pub purpose: u32,
pub coin_type: u32,
pub account: u32,
pub change: u32,
pub address_index: u32,
}
impl DerivationPath {
pub fn aingle(account: u32, index: u32) -> Self {
Self {
purpose: 44,
coin_type: AINGLE_COIN_TYPE,
account,
change: 0,
address_index: index,
}
}
pub fn to_string(&self) -> String {
format!(
"m/44'/{}'/{}'/{}/{}",
self.coin_type, self.account, self.change, self.address_index
)
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(20);
bytes.extend_from_slice(&(self.purpose | 0x80000000).to_be_bytes());
bytes.extend_from_slice(&(self.coin_type | 0x80000000).to_be_bytes());
bytes.extend_from_slice(&(self.account | 0x80000000).to_be_bytes());
bytes.extend_from_slice(&self.change.to_be_bytes());
bytes.extend_from_slice(&self.address_index.to_be_bytes());
bytes
}
}
impl Default for DerivationPath {
fn default() -> Self {
Self::aingle(0, 0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HwPublicKey {
pub bytes: Vec<u8>,
pub path: DerivationPath,
pub chain_code: Option<Vec<u8>>,
}
impl HwPublicKey {
pub fn to_hex(&self) -> String {
self.bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HwSignature {
pub bytes: Vec<u8>,
pub path: DerivationPath,
pub hash: Vec<u8>,
}
impl HwSignature {
pub fn to_hex(&self) -> String {
self.bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
pub fn is_valid_length(&self) -> bool {
self.bytes.len() == 64 }
}
#[derive(Debug, Clone, Default)]
pub struct WalletStats {
pub connections: u64,
pub keys_retrieved: u64,
pub signatures_created: u64,
pub failures: u64,
pub confirmation_wait_ms: u64,
}
pub struct WalletManager {
config: WalletConfig,
state: WalletState,
wallet_info: Option<WalletInfo>,
stats: WalletStats,
last_connected: Option<Instant>,
#[cfg(feature = "hw_wallet")]
transport: Option<TransportNativeHID>,
}
impl WalletManager {
pub fn new(config: WalletConfig) -> Self {
Self {
config,
state: WalletState::Disconnected,
wallet_info: None,
stats: WalletStats::default(),
last_connected: None,
#[cfg(feature = "hw_wallet")]
transport: None,
}
}
pub async fn connect(&mut self) -> Result<WalletInfo> {
if self.state == WalletState::Connected {
if let Some(info) = &self.wallet_info {
return Ok(info.clone());
}
}
self.state = WalletState::Connecting;
log::info!("Connecting to hardware wallet...");
#[cfg(feature = "hw_wallet")]
{
let api = HidApi::new()
.map_err(|e| Error::network(format!("Failed to initialize HID API: {}", e)))?;
let transport = TransportNativeHID::new(&api)
.map_err(|e| Error::network(format!("Failed to open HID transport: {}", e)))?;
let version_cmd = APDUCommand {
cla: AINGLE_CLA,
ins: ins::GET_VERSION,
p1: 0x00,
p2: 0x00,
data: vec![],
};
let answer = transport
.exchange(&version_cmd)
.map_err(|e| Error::network(format!("APDU exchange failed: {}", e)))?;
let (wallet_type, firmware_version) = if answer.data().len() >= 3 {
let major = answer.data()[0];
let minor = answer.data()[1];
let patch = answer.data()[2];
(
WalletType::LedgerNanoS, format!("{}.{}.{}", major, minor, patch),
)
} else {
(WalletType::Unknown, "0.0.0".to_string())
};
self.transport = Some(transport);
let info = WalletInfo {
wallet_type,
firmware_version,
app_version: None,
has_aingle_app: true, serial: None,
};
self.wallet_info = Some(info.clone());
self.state = WalletState::Connected;
self.last_connected = Some(Instant::now());
self.stats.connections += 1;
log::info!("Connected to hardware wallet: {:?}", info.wallet_type);
return Ok(info);
}
#[cfg(not(feature = "hw_wallet"))]
{
let info = WalletInfo {
wallet_type: WalletType::Unknown,
firmware_version: "0.0.0".to_string(),
app_version: None,
has_aingle_app: false,
serial: None,
};
self.wallet_info = Some(info.clone());
self.state = WalletState::Connected;
self.last_connected = Some(Instant::now());
self.stats.connections += 1;
log::info!(
"Connected to hardware wallet (simulated): {:?}",
info.wallet_type
);
Ok(info)
}
}
pub async fn disconnect(&mut self) -> Result<()> {
if self.state == WalletState::Disconnected {
return Ok(());
}
log::info!("Disconnecting from hardware wallet");
#[cfg(feature = "hw_wallet")]
{
self.transport = None;
}
self.state = WalletState::Disconnected;
self.wallet_info = None;
Ok(())
}
pub async fn get_public_key(&mut self, path: &DerivationPath) -> Result<HwPublicKey> {
if self.state != WalletState::Connected {
return Err(Error::network("Wallet not connected".to_string()));
}
log::debug!("Getting public key for path: {}", path.to_string());
#[cfg(feature = "hw_wallet")]
{
let transport = self
.transport
.as_ref()
.ok_or_else(|| Error::network("Transport not initialized".to_string()))?;
let cmd = APDUCommand {
cla: AINGLE_CLA,
ins: ins::GET_PUBLIC_KEY,
p1: 0x00,
p2: 0x00,
data: path.to_bytes(),
};
let answer = transport
.exchange(&cmd)
.map_err(|e| Error::network(format!("APDU exchange failed: {}", e)))?;
if answer.retcode() != 0x9000 {
let error_msg = match answer.retcode() {
0x6985 => "User rejected",
0x6A82 => "App not found",
0x6D00 => "Invalid instruction",
_ => "Unknown error",
};
return Err(Error::network(format!(
"Device error: {} (0x{:04X})",
error_msg,
answer.retcode()
)));
}
let data = answer.data();
if data.len() < 32 {
return Err(Error::Serialization(
"Invalid public key response length".to_string(),
));
}
let public_key = HwPublicKey {
bytes: data[0..32].to_vec(),
path: path.clone(),
chain_code: if data.len() >= 64 {
Some(data[32..64].to_vec())
} else {
None
},
};
self.stats.keys_retrieved += 1;
return Ok(public_key);
}
#[cfg(not(feature = "hw_wallet"))]
{
let public_key = HwPublicKey {
bytes: vec![0u8; 32],
path: path.clone(),
chain_code: None,
};
self.stats.keys_retrieved += 1;
Ok(public_key)
}
}
pub async fn sign_hash(&mut self, hash: &[u8], path: &DerivationPath) -> Result<HwSignature> {
if self.state != WalletState::Connected {
return Err(Error::network("Wallet not connected".to_string()));
}
if hash.len() != 32 {
return Err(Error::InvalidEntry("Hash must be 32 bytes".to_string()));
}
log::info!("Requesting signature from hardware wallet...");
log::info!("Please confirm on your device");
self.state = WalletState::AwaitingConfirmation;
let start = Instant::now();
#[cfg(feature = "hw_wallet")]
{
let transport = self
.transport
.as_ref()
.ok_or_else(|| Error::network("Transport not initialized".to_string()))?;
let mut data = path.to_bytes();
data.extend_from_slice(hash);
let cmd = APDUCommand {
cla: AINGLE_CLA,
ins: ins::SIGN_HASH,
p1: 0x00,
p2: 0x00,
data,
};
let answer = transport.exchange(&cmd).map_err(|e| {
self.state = WalletState::Connected;
self.stats.failures += 1;
Error::network(format!("APDU exchange failed: {}", e))
})?;
if answer.retcode() != 0x9000 {
self.state = WalletState::Connected;
self.stats.failures += 1;
let error_msg = match answer.retcode() {
0x6985 => "User rejected the signature request",
0x6982 => "Security status not satisfied",
0x6A80 => "Invalid data",
0x6FAA => "Device is locked",
_ => "Unknown error",
};
return Err(Error::network(format!(
"Signing failed: {} (0x{:04X})",
error_msg,
answer.retcode()
)));
}
let sig_data = answer.data();
if sig_data.len() < 64 {
self.state = WalletState::Connected;
return Err(Error::Serialization(format!(
"Invalid signature length: expected 64, got {}",
sig_data.len()
)));
}
let signature = HwSignature {
bytes: sig_data[0..64].to_vec(),
path: path.clone(),
hash: hash.to_vec(),
};
self.state = WalletState::Connected;
self.stats.signatures_created += 1;
self.stats.confirmation_wait_ms += start.elapsed().as_millis() as u64;
log::info!("Signature received from hardware wallet");
return Ok(signature);
}
#[cfg(not(feature = "hw_wallet"))]
{
let signature = HwSignature {
bytes: vec![0u8; 64],
path: path.clone(),
hash: hash.to_vec(),
};
self.state = WalletState::Connected;
self.stats.signatures_created += 1;
self.stats.confirmation_wait_ms += start.elapsed().as_millis() as u64;
log::info!("Signature received from hardware wallet (simulated)");
Ok(signature)
}
}
pub fn state(&self) -> WalletState {
self.state
}
pub fn is_connected(&self) -> bool {
self.state == WalletState::Connected
}
pub fn wallet_info(&self) -> Option<&WalletInfo> {
self.wallet_info.as_ref()
}
pub fn stats(&self) -> &WalletStats {
&self.stats
}
pub fn connection_duration(&self) -> Option<Duration> {
self.last_connected.map(|t| t.elapsed())
}
}
#[derive(Debug, Clone)]
pub struct ApduCommand {
pub cla: u8,
pub ins: u8,
pub p1: u8,
pub p2: u8,
pub data: Vec<u8>,
}
impl ApduCommand {
pub fn new(cla: u8, ins: u8, p1: u8, p2: u8) -> Self {
Self {
cla,
ins,
p1,
p2,
data: Vec::new(),
}
}
pub fn with_data(mut self, data: Vec<u8>) -> Self {
self.data = data;
self
}
pub fn serialize(&self) -> std::result::Result<Vec<u8>, crate::error::Error> {
if self.data.len() > 255 {
return Err(crate::error::Error::network(
format!("APDU data too large: {} bytes (max 255)", self.data.len()),
));
}
let mut bytes = Vec::with_capacity(5 + self.data.len());
bytes.push(self.cla);
bytes.push(self.ins);
bytes.push(self.p1);
bytes.push(self.p2);
if !self.data.is_empty() {
bytes.push(self.data.len() as u8);
bytes.extend_from_slice(&self.data);
}
Ok(bytes)
}
}
#[derive(Debug, Clone)]
pub struct ApduResponse {
pub data: Vec<u8>,
pub status: u16,
}
impl ApduResponse {
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
if bytes.len() < 2 {
return Err(Error::Serialization("Response too short".to_string()));
}
let status = u16::from_be_bytes([bytes[bytes.len() - 2], bytes[bytes.len() - 1]]);
let data = bytes[..bytes.len() - 2].to_vec();
Ok(Self { data, status })
}
pub fn is_success(&self) -> bool {
self.status == 0x9000
}
pub fn error_message(&self) -> Option<&'static str> {
match self.status {
0x9000 => None, 0x6985 => Some("User rejected"),
0x6982 => Some("Security status not satisfied"),
0x6A80 => Some("Invalid data"),
0x6A82 => Some("App not found"),
0x6D00 => Some("Invalid instruction"),
0x6E00 => Some("Invalid CLA"),
0x6F00 => Some("Internal error"),
0x6FAA => Some("Device locked"),
_ => Some("Unknown error"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wallet_config_default() {
let config = WalletConfig::default();
assert_eq!(config.timeout, Duration::from_secs(30));
assert!(config.auto_retry);
assert!(!config.use_ble);
}
#[test]
fn test_wallet_config_ble() {
let config = WalletConfig::with_ble();
assert!(config.use_ble);
assert_eq!(config.timeout, Duration::from_secs(60));
}
#[test]
fn test_wallet_type_ble_support() {
assert!(WalletType::LedgerNanoX.supports_ble());
assert!(!WalletType::LedgerNanoS.supports_ble());
assert!(!WalletType::TrezorOne.supports_ble());
}
#[test]
fn test_wallet_type_name() {
assert_eq!(WalletType::LedgerNanoS.name(), "Ledger Nano S");
assert_eq!(WalletType::TrezorModelT.name(), "Trezor Model T");
}
#[test]
fn test_derivation_path_aingle() {
let path = DerivationPath::aingle(0, 0);
assert_eq!(path.purpose, 44);
assert_eq!(path.coin_type, AINGLE_COIN_TYPE);
assert_eq!(path.account, 0);
assert_eq!(path.change, 0);
assert_eq!(path.address_index, 0);
}
#[test]
fn test_derivation_path_to_string() {
let path = DerivationPath::aingle(0, 5);
assert_eq!(path.to_string(), "m/44'/8017'/0'/0/5");
}
#[test]
fn test_derivation_path_to_bytes() {
let path = DerivationPath::aingle(0, 0);
let bytes = path.to_bytes();
assert_eq!(bytes.len(), 20); }
#[test]
fn test_hw_public_key_to_hex() {
let key = HwPublicKey {
bytes: vec![0xAB, 0xCD, 0xEF],
path: DerivationPath::default(),
chain_code: None,
};
assert_eq!(key.to_hex(), "abcdef");
}
#[test]
fn test_hw_signature_valid_length() {
let sig = HwSignature {
bytes: vec![0u8; 64],
path: DerivationPath::default(),
hash: vec![0u8; 32],
};
assert!(sig.is_valid_length());
let invalid_sig = HwSignature {
bytes: vec![0u8; 32],
path: DerivationPath::default(),
hash: vec![0u8; 32],
};
assert!(!invalid_sig.is_valid_length());
}
#[test]
fn test_wallet_manager_creation() {
let manager = WalletManager::new(WalletConfig::default());
assert_eq!(manager.state(), WalletState::Disconnected);
assert!(!manager.is_connected());
assert!(manager.wallet_info().is_none());
}
#[test]
fn test_wallet_stats_default() {
let stats = WalletStats::default();
assert_eq!(stats.connections, 0);
assert_eq!(stats.signatures_created, 0);
}
#[test]
fn test_apdu_command_new() {
let cmd = ApduCommand::new(0xE0, 0x01, 0x00, 0x00);
assert_eq!(cmd.cla, 0xE0);
assert_eq!(cmd.ins, 0x01);
assert!(cmd.data.is_empty());
}
#[test]
fn test_apdu_command_serialize() {
let cmd = ApduCommand::new(0xE0, 0x01, 0x00, 0x00).with_data(vec![0x01, 0x02, 0x03]);
let bytes = cmd.serialize().unwrap();
assert_eq!(bytes, vec![0xE0, 0x01, 0x00, 0x00, 0x03, 0x01, 0x02, 0x03]);
}
#[test]
fn test_apdu_response_success() {
let response = ApduResponse {
data: vec![0x01, 0x02],
status: 0x9000,
};
assert!(response.is_success());
assert!(response.error_message().is_none());
}
#[test]
fn test_apdu_response_error() {
let response = ApduResponse {
data: vec![],
status: 0x6985,
};
assert!(!response.is_success());
assert_eq!(response.error_message(), Some("User rejected"));
}
#[test]
fn test_apdu_response_from_bytes() {
let bytes = vec![0x01, 0x02, 0x90, 0x00];
let response = ApduResponse::from_bytes(&bytes).unwrap();
assert_eq!(response.data, vec![0x01, 0x02]);
assert_eq!(response.status, 0x9000);
}
}