use bip32::{DerivationPath, XPrv};
use bip39::Mnemonic;
use secp256k1::{PublicKey, Secp256k1, SecretKey};
use sha3::{Digest, Keccak256};
use std::fmt;
use std::str::FromStr;
use zeroize::Zeroize;
use log::{debug, info};
use crate::error::{Result, WalletError};
const ETH_DERIVATION_PATH: &str = "m/44'/60'/0'/0/0";
const SOL_DERIVATION_PATH: &str = "m/44'/501'/0'/0'";
pub struct Wallet {
seed: Vec<u8>,
mnemonic_phrase: String,
}
impl Wallet {
pub fn from_mnemonic(phrase: &str) -> Result<Self> {
let mnemonic = Mnemonic::parse(phrase)
.map_err(|e| WalletError::InvalidMnemonic(e.to_string()))?;
let seed = mnemonic.to_seed("");
if seed.len() < 16 {
return Err(WalletError::InvalidSeed(
"seed is too short".to_string(),
));
}
info!("wallet created from mnemonic");
Ok(Self {
seed: seed.to_vec(),
mnemonic_phrase: phrase.to_string(),
})
}
pub fn derive_eth_address(&self) -> Result<String> {
let path = DerivationPath::from_str(ETH_DERIVATION_PATH)
.map_err(|e| WalletError::DerivationError(e.to_string()))?;
let child_xprv = XPrv::derive_from_path(&self.seed, &path)
.map_err(|e| WalletError::DerivationError(e.to_string()))?;
let secret_key = SecretKey::from_slice(&child_xprv.to_bytes())
.map_err(|e| WalletError::CryptoError(e.to_string()))?;
let secp = Secp256k1::new();
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
let uncompressed = public_key.serialize_uncompressed();
let pub_key_bytes = &uncompressed[1..];
let mut hasher = Keccak256::new();
hasher.update(pub_key_bytes);
let hash = hasher.finalize();
let raw_address = &hash[12..];
let address_hex = hex::encode(raw_address);
let checksummed = eip55_checksum(&address_hex);
debug!("derived ETH address: 0x{checksummed}");
Ok(format!("0x{checksummed}"))
}
pub fn derive_solana_address(&self) -> Result<String> {
let path = DerivationPath::from_str(SOL_DERIVATION_PATH)
.map_err(|e| WalletError::DerivationError(e.to_string()))?;
let child_xprv = XPrv::derive_from_path(&self.seed, &path)
.map_err(|e| WalletError::DerivationError(e.to_string()))?;
let secret_key = SecretKey::from_slice(&child_xprv.to_bytes())
.map_err(|e| WalletError::CryptoError(e.to_string()))?;
let secp = Secp256k1::new();
let public_key = PublicKey::from_secret_key(&secp, &secret_key);
let compressed = public_key.serialize();
let addr = hex::encode(compressed);
debug!("derived SOL address: {addr}");
Ok(addr)
}
pub fn mnemonic(&self) -> &str {
&self.mnemonic_phrase
}
}
impl fmt::Display for Wallet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let eth = self
.derive_eth_address()
.unwrap_or_else(|e| format!("<error: {e}>"));
let sol = self
.derive_solana_address()
.unwrap_or_else(|e| format!("<error: {e}>"));
writeln!(f, "=== Xorion Multi-Chain Wallet ===")?;
writeln!(f, "Ethereum : {eth}")?;
writeln!(f, "Solana : {sol}")
}
}
impl Drop for Wallet {
fn drop(&mut self) {
self.seed.zeroize();
unsafe {
let bytes = self.mnemonic_phrase.as_bytes_mut();
bytes.zeroize();
}
}
}
fn eip55_checksum(address: &str) -> String {
let mut hasher = Keccak256::new();
hasher.update(address.as_bytes());
let hash = hex::encode(hasher.finalize());
address
.chars()
.enumerate()
.map(|(i, c)| {
if c.is_ascii_alphabetic() {
let nibble = u8::from_str_radix(&hash[i..i + 1], 16).unwrap_or(0);
if nibble >= 8 {
c.to_ascii_uppercase()
} else {
c.to_ascii_lowercase()
}
} else {
c
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_MNEMONIC: &str =
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
#[test]
fn wallet_from_valid_mnemonic() {
let wallet = Wallet::from_mnemonic(TEST_MNEMONIC);
assert!(wallet.is_ok());
}
#[test]
fn wallet_from_invalid_mnemonic() {
let wallet = Wallet::from_mnemonic("invalid mnemonic phrase");
assert!(wallet.is_err());
}
#[test]
fn derive_eth_address_succeeds() {
let wallet = Wallet::from_mnemonic(TEST_MNEMONIC).unwrap();
let addr = wallet.derive_eth_address().unwrap();
assert!(addr.starts_with("0x"));
assert_eq!(addr.len(), 42);
}
#[test]
fn derive_solana_address_succeeds() {
let wallet = Wallet::from_mnemonic(TEST_MNEMONIC).unwrap();
let addr = wallet.derive_solana_address().unwrap();
assert!(!addr.is_empty());
}
#[test]
fn display_shows_both_chains() {
let wallet = Wallet::from_mnemonic(TEST_MNEMONIC).unwrap();
let output = format!("{wallet}");
assert!(output.contains("Ethereum"));
assert!(output.contains("Solana"));
}
}