Skip to main content

hw_wallet/
chain.rs

1pub use hw_chain::{
2    Chain, ChainConfig, DEFAULT_BITCOIN_BIP32_PATH, DEFAULT_ETHEREUM_BIP32_PATH,
3    DEFAULT_SOLANA_BIP32_PATH,
4};
5
6use crate::bip32::{HARDENED, parse_bip32_path};
7use crate::error::{WalletError, WalletResult};
8
9pub trait ChainPathExt: Sized {
10    fn from_bip32_path(path: &[u32]) -> Option<Self>;
11    fn validate_signing_path(self, path: &[u32], operation: &str) -> WalletResult<()>;
12}
13
14impl ChainPathExt for Chain {
15    fn from_bip32_path(path: &[u32]) -> Option<Self> {
16        let coin_type = path.get(1).copied()? & !HARDENED;
17        Self::from_slip44(coin_type)
18    }
19
20    fn validate_signing_path(self, path: &[u32], operation: &str) -> WalletResult<()> {
21        // Firmware accepts the Ledger-compatible m/44'/501' root for Solana.
22        let min_segments = match self {
23            Self::Solana => 2,
24            Self::Ethereum | Self::Bitcoin => 3,
25        };
26        if path.len() < min_segments {
27            return Err(WalletError::InvalidBip32Path(format!(
28                "{operation} path must contain at least {min_segments} segments for {self:?}"
29            )));
30        }
31
32        if let Some(inferred) = Self::from_bip32_path(path)
33            && inferred != self
34        {
35            return Err(WalletError::InvalidBip32Path(format!(
36                "chain/path mismatch: explicit {self:?} conflicts with inferred {inferred:?}"
37            )));
38        }
39
40        Ok(())
41    }
42}
43
44#[derive(Debug, Clone, Eq, PartialEq)]
45pub struct ResolvedDerivationPath {
46    pub chain: Chain,
47    pub path: String,
48    pub path_indices: Vec<u32>,
49}
50
51impl ResolvedDerivationPath {
52    pub fn resolve(
53        explicit_chain: Option<Chain>,
54        explicit_path: Option<&str>,
55    ) -> WalletResult<Self> {
56        let parsed_path = explicit_path
57            .map(|path| parse_bip32_path(path).map(|indices| (path.to_owned(), indices)))
58            .transpose()?;
59
60        let inferred_chain = parsed_path
61            .as_ref()
62            .and_then(|(_, indices)| Chain::from_bip32_path(indices));
63        let chain = explicit_chain.or(inferred_chain).unwrap_or(Chain::Ethereum);
64
65        if let (Some(explicit), Some(inferred), Some(path)) =
66            (explicit_chain, inferred_chain, explicit_path)
67            && explicit != inferred
68        {
69            return Err(WalletError::InvalidBip32Path(format!(
70                "chain/path mismatch: explicit {explicit:?} conflicts with inferred {inferred:?} from path '{path}'"
71            )));
72        }
73
74        let (path, path_indices) = match parsed_path {
75            Some(parsed) => parsed,
76            None => {
77                let default_path = chain.default_path().to_owned();
78                let path_indices = parse_bip32_path(&default_path)?;
79                (default_path, path_indices)
80            }
81        };
82
83        Ok(Self {
84            chain,
85            path,
86            path_indices,
87        })
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn infer_chain_from_coin_type() {
97        for (coin_type, expected) in [
98            (0x8000_003c, Chain::Ethereum),
99            (0x8000_0000, Chain::Bitcoin),
100            (0x8000_01f5, Chain::Solana),
101        ] {
102            assert_eq!(
103                Chain::from_bip32_path(&[0x8000_002c, coin_type]),
104                Some(expected)
105            );
106        }
107    }
108
109    #[test]
110    fn signing_path_minimum_length_is_chain_aware() {
111        let solana_root = [44 | HARDENED, 501 | HARDENED];
112        assert!(
113            Chain::Solana
114                .validate_signing_path(&solana_root, "sign")
115                .is_ok()
116        );
117        assert!(
118            Chain::Solana
119                .validate_signing_path(&solana_root[..1], "sign")
120                .is_err()
121        );
122        let eth_short = [44 | HARDENED, 60 | HARDENED];
123        assert!(
124            Chain::Ethereum
125                .validate_signing_path(&eth_short, "sign")
126                .is_err()
127        );
128    }
129
130    #[test]
131    fn resolve_defaults_to_eth_when_empty() {
132        let resolved = ResolvedDerivationPath::resolve(None, None).expect("default resolution");
133        assert_eq!(resolved.chain, Chain::Ethereum);
134        assert_eq!(resolved.path, DEFAULT_ETHEREUM_BIP32_PATH);
135    }
136
137    #[test]
138    fn resolve_uses_chain_default_path() {
139        for chain in Chain::ALL {
140            let resolved =
141                ResolvedDerivationPath::resolve(Some(chain), None).expect("default path");
142            assert_eq!(resolved.chain, chain);
143            assert_eq!(resolved.path, chain.default_path());
144        }
145    }
146
147    #[test]
148    fn resolve_rejects_chain_path_mismatch() {
149        let err = ResolvedDerivationPath::resolve(
150            Some(Chain::Bitcoin),
151            Some(DEFAULT_ETHEREUM_BIP32_PATH),
152        )
153        .expect_err("mismatch should fail");
154        assert!(
155            err.to_string().contains("chain/path mismatch"),
156            "unexpected error: {err}"
157        );
158    }
159}