Skip to main content

totalreclaw_core/
wallet.rs

1//! Ethereum wallet derivation from BIP-39 mnemonic.
2//!
3//! Derives the EOA (externally-owned account) address and private key
4//! via BIP-44 path m/44'/60'/0'/0/0, matching viem's mnemonicToAccount().
5//!
6//! This module contains only pure crypto -- no network I/O, no filesystem.
7
8use coins_bip32::prelude::*;
9use k256::ecdsa::SigningKey;
10use serde::{Deserialize, Serialize, Serializer};
11use tiny_keccak::{Hasher, Keccak};
12
13use crate::{Error, Result};
14
15/// Derived Ethereum wallet (EOA + signing key).
16///
17/// Private key is serialized as a hex string for WASM/PyO3 interop.
18#[derive(Debug, Clone, Deserialize)]
19pub struct EthWallet {
20    /// Private key bytes (32 bytes).
21    #[serde(deserialize_with = "deserialize_privkey_hex")]
22    pub private_key: [u8; 32],
23    /// EOA address (0x-prefixed, lowercase hex).
24    pub address: String,
25}
26
27impl Serialize for EthWallet {
28    fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
29        use serde::ser::SerializeStruct;
30        let mut s = serializer.serialize_struct("EthWallet", 2)?;
31        s.serialize_field("private_key", &hex::encode(self.private_key))?;
32        s.serialize_field("address", &self.address)?;
33        s.end()
34    }
35}
36
37fn deserialize_privkey_hex<'de, D: serde::Deserializer<'de>>(
38    deserializer: D,
39) -> std::result::Result<[u8; 32], D::Error> {
40    let s = String::deserialize(deserializer)?;
41    let bytes = hex::decode(&s).map_err(serde::de::Error::custom)?;
42    if bytes.len() != 32 {
43        return Err(serde::de::Error::custom(format!(
44            "expected 32 bytes, got {}",
45            bytes.len()
46        )));
47    }
48    let mut arr = [0u8; 32];
49    arr.copy_from_slice(&bytes);
50    Ok(arr)
51}
52
53/// Derive an Ethereum EOA from a BIP-39 mnemonic via BIP-44.
54///
55/// Path: m/44'/60'/0'/0/0 (standard Ethereum derivation path).
56/// Matches viem's `mnemonicToAccount(mnemonic)`.
57pub fn derive_eoa(mnemonic: &str) -> Result<EthWallet> {
58    // 1. BIP-39 seed (PBKDF2-HMAC-SHA512)
59    let seed = crate::crypto::mnemonic_to_seed_bytes(mnemonic)?;
60
61    // 2. BIP-32 master key from seed
62    let master = XPriv::root_from_seed(&seed, None)
63        .map_err(|e| Error::Crypto(format!("BIP-32 master key failed: {}", e)))?;
64
65    // 3. Derive m/44'/60'/0'/0/0
66    let path = "m/44'/60'/0'/0/0";
67    let derived = master
68        .derive_path(path)
69        .map_err(|e| Error::Crypto(format!("BIP-44 derivation failed: {}", e)))?;
70
71    // 4. Extract 32-byte private key (via AsRef<SigningKey>)
72    let derived_signing_key: &k256::ecdsa::SigningKey = derived.as_ref();
73    let mut private_key = [0u8; 32];
74    private_key.copy_from_slice(&derived_signing_key.to_bytes());
75
76    // 5. Derive public key -> keccak256 -> last 20 bytes = address
77    let signing_key = SigningKey::from_bytes((&private_key).into())
78        .map_err(|e| Error::Crypto(format!("Invalid private key: {}", e)))?;
79    let verifying_key = signing_key.verifying_key();
80    let public_key_bytes = verifying_key.to_encoded_point(false);
81    // Uncompressed public key: 0x04 || x (32 bytes) || y (32 bytes)
82    // Keccak256 the 64 bytes (skip the 0x04 prefix)
83    let pubkey_raw = &public_key_bytes.as_bytes()[1..]; // skip 0x04
84
85    let mut keccak = Keccak::v256();
86    let mut hash = [0u8; 32];
87    keccak.update(pubkey_raw);
88    keccak.finalize(&mut hash);
89
90    // Address = last 20 bytes of keccak256(pubkey)
91    let address = format!("0x{}", hex::encode(&hash[12..]));
92
93    Ok(EthWallet {
94        private_key,
95        address,
96    })
97}
98
99/// Convenience: derive just the address string.
100pub fn derive_eoa_address(mnemonic: &str) -> Result<String> {
101    Ok(derive_eoa(mnemonic)?.address)
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    /// The 12-word "abandon...about" mnemonic's EOA at BIP-44 m/44'/60'/0'/0/0.
109    /// Verified against Python eth_account.Account.from_mnemonic() and iancoleman.io/bip39.
110    #[test]
111    fn test_eoa_derivation_parity() {
112        let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
113        let w = derive_eoa(mnemonic).unwrap();
114        assert_eq!(
115            hex::encode(&w.private_key),
116            "1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727",
117            "Private key must match reference derivation"
118        );
119        assert_eq!(
120            w.address.to_lowercase(),
121            "0x9858effd232b4033e47d90003d41ec34ecaeda94",
122            "EOA must match eth_account.from_mnemonic for the 12-word test mnemonic"
123        );
124    }
125
126    #[test]
127    fn test_derive_eoa_address() {
128        let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
129        let address = derive_eoa_address(mnemonic).unwrap();
130        assert_eq!(
131            address.to_lowercase(),
132            "0x9858effd232b4033e47d90003d41ec34ecaeda94"
133        );
134    }
135
136    #[test]
137    fn test_ethwallet_serialization() {
138        let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
139        let w = derive_eoa(mnemonic).unwrap();
140        let json = serde_json::to_string(&w).unwrap();
141        assert!(json.contains("1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727"));
142        assert!(json.contains("0x9858effd232b4033e47d90003d41ec34ecaeda94"));
143
144        // Round-trip deserialization
145        let w2: EthWallet = serde_json::from_str(&json).unwrap();
146        assert_eq!(w.private_key, w2.private_key);
147        assert_eq!(w.address, w2.address);
148    }
149}