Skip to main content

metaflux_client/wallet/
key.rs

1//! Key + address derivation.
2//!
3//! Holds the [`Wallet`] struct (an owning wrapper around a `SecretKey`) and
4//! the [`Address`] newtype representing a 20-byte EVM address.
5//!
6//! Signing methods live in [`crate::wallet::sign`]; this file only handles
7//! key parsing, address derivation, and key lifecycle (construction, debug
8//! redaction).
9
10use core::fmt;
11
12use k256::ecdsa::SigningKey;
13use serde::{Deserialize, Serialize};
14use tiny_keccak::{Hasher, Keccak};
15
16use crate::error::ClientError;
17
18/// 20-byte EVM-compatible address (last 20 bytes of `keccak256(pubkey)`).
19///
20/// The address is the canonical user identifier in EIP-712 messages and in
21/// the MTF state machine's `Address` field.
22#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[serde(transparent)]
24pub struct Address(#[serde(with = "hex_array_20")] pub [u8; 20]);
25
26impl Address {
27    /// Zero address (all 20 bytes = 0).
28    pub const ZERO: Self = Self([0u8; 20]);
29
30    /// Wrap a raw 20-byte address.
31    #[must_use]
32    pub const fn from_bytes(bytes: [u8; 20]) -> Self {
33        Self(bytes)
34    }
35
36    /// Parse a hex address (with or without `0x` prefix).
37    ///
38    /// # Errors
39    /// Returns [`ClientError::InvalidKey`] if the input is not 40 hex chars
40    /// or not valid hex.
41    pub fn from_hex(s: &str) -> Result<Self, ClientError> {
42        let stripped = s.strip_prefix("0x").unwrap_or(s);
43        if stripped.len() != 40 {
44            return Err(ClientError::InvalidKey(format!(
45                "address hex must be 40 chars, got {}",
46                stripped.len()
47            )));
48        }
49        let bytes = hex::decode(stripped)
50            .map_err(|e| ClientError::InvalidKey(format!("address hex decode: {e}")))?;
51        let mut out = [0u8; 20];
52        out.copy_from_slice(&bytes);
53        Ok(Self(out))
54    }
55
56    /// Raw 20-byte payload.
57    #[must_use]
58    pub const fn as_bytes(&self) -> &[u8; 20] {
59        &self.0
60    }
61}
62
63impl fmt::Debug for Address {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        write!(f, "0x{}", hex::encode(self.0))
66    }
67}
68
69impl fmt::Display for Address {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "0x{}", hex::encode(self.0))
72    }
73}
74
75/// Serde helper: encode `[u8; 20]` as a 0x-prefixed lowercase hex string.
76mod hex_array_20 {
77    use serde::{Deserialize, Deserializer, Serializer};
78
79    pub fn serialize<S: Serializer>(bytes: &[u8; 20], s: S) -> Result<S::Ok, S::Error> {
80        let hex_str = format!("0x{}", hex::encode(bytes));
81        s.serialize_str(&hex_str)
82    }
83
84    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 20], D::Error> {
85        let s: String = String::deserialize(d)?;
86        let stripped = s.strip_prefix("0x").unwrap_or(&s);
87        if stripped.len() != 40 {
88            return Err(serde::de::Error::custom(format!(
89                "address hex must be 40 chars, got {}",
90                stripped.len()
91            )));
92        }
93        let bytes = hex::decode(stripped).map_err(serde::de::Error::custom)?;
94        let mut out = [0u8; 20];
95        out.copy_from_slice(&bytes);
96        Ok(out)
97    }
98}
99
100/// An owning secp256k1 wallet.
101///
102/// Construction is explicit (`from_bytes` / `from_hex` / `random_for_testing`).
103/// The inner `SigningKey` zeroizes on drop automatically. `Debug` does **not**
104/// reveal the key material — only the derived address.
105#[derive(Clone)]
106pub struct Wallet {
107    key: SigningKey,
108    address: Address,
109}
110
111impl Wallet {
112    /// Construct from a 32-byte secret scalar.
113    ///
114    /// # Errors
115    /// Returns [`ClientError::InvalidKey`] if the bytes are not a valid
116    /// secp256k1 scalar (zero or ≥ curve order).
117    pub fn from_bytes(bytes: [u8; 32]) -> Result<Self, ClientError> {
118        let key = SigningKey::from_bytes(&bytes.into())
119            .map_err(|e| ClientError::InvalidKey(e.to_string()))?;
120        let address = address_from_signing_key(&key);
121        Ok(Self { key, address })
122    }
123
124    /// Construct from a hex private key (with or without `0x` prefix).
125    ///
126    /// # Errors
127    /// Returns [`ClientError::InvalidKey`] on wrong length / non-hex / not a
128    /// valid scalar.
129    pub fn from_hex(s: &str) -> Result<Self, ClientError> {
130        let stripped = s.strip_prefix("0x").unwrap_or(s);
131        if stripped.len() != 64 {
132            return Err(ClientError::InvalidKey(format!(
133                "private key hex must be 64 chars, got {}",
134                stripped.len()
135            )));
136        }
137        let raw = hex::decode(stripped)
138            .map_err(|e| ClientError::InvalidKey(format!("private key hex decode: {e}")))?;
139        let mut bytes = [0u8; 32];
140        bytes.copy_from_slice(&raw);
141        Self::from_bytes(bytes)
142    }
143
144    /// Generate a random wallet for tests.
145    ///
146    /// **Do not use this for production keys.** It pulls from `OsRng`; for
147    /// non-test paths, derive keys deterministically from a known seed (e.g.
148    /// BIP-39 mnemonic via a separate crate).
149    #[doc(hidden)]
150    #[must_use]
151    pub fn random_for_testing() -> Self {
152        let key = SigningKey::random(&mut k256::elliptic_curve::rand_core::OsRng);
153        let address = address_from_signing_key(&key);
154        Self { key, address }
155    }
156
157    /// Derived 20-byte EVM address.
158    #[must_use]
159    pub const fn address(&self) -> Address {
160        self.address
161    }
162
163    /// Internal accessor for the signing key (kept crate-private to keep
164    /// the secret surface tight; signing goes through [`Wallet::sign_eip712`]
165    /// and friends).
166    pub(crate) const fn signing_key(&self) -> &SigningKey {
167        &self.key
168    }
169}
170
171impl fmt::Debug for Wallet {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        f.debug_struct("Wallet")
174            .field("address", &self.address)
175            .field("key", &"<redacted>")
176            .finish()
177    }
178}
179
180/// Compute the 20-byte EVM address from a `SigningKey`.
181///
182/// Returns `keccak256(uncompressed_pubkey_64)[12..32]` where
183/// `uncompressed_pubkey_64` is the 64-byte X || Y of the public key (without
184/// the `0x04` SEC1 prefix).
185fn address_from_signing_key(key: &SigningKey) -> Address {
186    let verifying = key.verifying_key();
187    let point = verifying.to_encoded_point(/* compress= */ false);
188    let pubkey_bytes = point.as_bytes(); // 65 bytes (0x04 || X || Y)
189    debug_assert_eq!(pubkey_bytes.len(), 65);
190    debug_assert_eq!(pubkey_bytes[0], 0x04);
191
192    let mut hasher = Keccak::v256();
193    hasher.update(&pubkey_bytes[1..]); // skip the 0x04 prefix
194    let mut digest = [0u8; 32];
195    hasher.finalize(&mut digest);
196
197    let mut addr = [0u8; 20];
198    addr.copy_from_slice(&digest[12..32]);
199    Address(addr)
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    /// EIP-55 spec vector: private key `0x4646...4646` -> address
207    /// `0x9d8a62f656a8d1615c1294fd71e9cfb3e4855a4f`. We don't enforce
208    /// EIP-55 checksum casing here since the SDK uses raw 20-byte
209    /// `Address`, but the lowercase hex must match.
210    #[test]
211    fn known_vector_eip55_book_example() {
212        // From the EIP-55 reference test vectors.
213        let priv_hex = "4646464646464646464646464646464646464646464646464646464646464646";
214        let wallet = Wallet::from_hex(priv_hex).unwrap();
215        let got = hex::encode(wallet.address().as_bytes());
216        // Address derived from this private key:
217        //   pubkey = secp256k1_g^k
218        //   keccak256(pubkey[1..])[12..]
219        // Calculated reference: 9d8a62f656a8d1615c1294fd71e9cfb3e4855a4f
220        assert_eq!(got, "9d8a62f656a8d1615c1294fd71e9cfb3e4855a4f");
221    }
222
223    #[test]
224    fn from_hex_accepts_0x_prefix() {
225        let a =
226            Wallet::from_hex("0x4646464646464646464646464646464646464646464646464646464646464646")
227                .unwrap();
228        let b =
229            Wallet::from_hex("4646464646464646464646464646464646464646464646464646464646464646")
230                .unwrap();
231        assert_eq!(a.address(), b.address());
232    }
233
234    #[test]
235    fn from_hex_rejects_short_input() {
236        let e = Wallet::from_hex("dead").unwrap_err();
237        assert!(matches!(e, ClientError::InvalidKey(_)));
238    }
239
240    #[test]
241    fn from_hex_rejects_non_hex() {
242        let e = Wallet::from_hex(&"z".repeat(64)).unwrap_err();
243        assert!(matches!(e, ClientError::InvalidKey(_)));
244    }
245
246    #[test]
247    fn address_parses_with_and_without_prefix() {
248        let a = Address::from_hex("0x9d8a62f656a8d1615c1294fd71e9cfb3e4855a4f").unwrap();
249        let b = Address::from_hex("9d8a62f656a8d1615c1294fd71e9cfb3e4855a4f").unwrap();
250        assert_eq!(a, b);
251    }
252
253    #[test]
254    fn address_display_lowercase_hex() {
255        let a = Address::from_bytes([0x9du8; 20]);
256        let s = format!("{a}");
257        assert!(s.starts_with("0x"));
258        assert_eq!(s.len(), 42);
259    }
260
261    #[test]
262    fn debug_redacts_secret() {
263        let w = Wallet::random_for_testing();
264        let dbg = format!("{w:?}");
265        assert!(dbg.contains("redacted"));
266        assert!(!dbg.to_lowercase().contains("signingkey"));
267    }
268}