pubport/
xpub.rs

1use std::str::FromStr as _;
2
3use bitcoin::{
4    base58,
5    bip32::{Fingerprint, Xpub as Bip32Xpub},
6};
7
8#[derive(Debug, thiserror::Error)]
9pub enum Error {
10    #[error("Invalid xpub: {0}")]
11    InvalidXpub(#[from] bitcoin::bip32::Error),
12
13    #[error("Invalid zpub: {0}")]
14    InvalidZpub(#[from] base58::Error),
15
16    #[error("Invalid ypub: {0}")]
17    InvalidYpubDecode(base58::Error),
18
19    #[error("Invalid ypub: {0}")]
20    InvalidYpubLength(usize),
21
22    #[error("Not an xpub, zpub or ypub, starts with: {0}")]
23    NotXpub(String),
24
25    #[error("Too short, only {0} chars long")]
26    TooShort(usize),
27
28    #[error("Missing xpub")]
29    MissingXpub,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Xpub {
34    xpub: Bip32Xpub,
35    original_format: OriginalFormat,
36}
37
38impl std::fmt::Display for Xpub {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        write!(f, "{}", self.xpub)
41    }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)]
45pub enum OriginalFormat {
46    Zpub,
47    Ypub,
48    Xpub,
49}
50
51impl Xpub {
52    pub fn master_fingerprint(&self) -> Option<Fingerprint> {
53        let fingerprint = xpub_to_fingerprint(&self.xpub).ok()?;
54        if fingerprint == Fingerprint::default() {
55            return None;
56        }
57
58        Some(fingerprint)
59    }
60
61    pub fn fingerprint(&self) -> Fingerprint {
62        self.xpub.fingerprint()
63    }
64}
65
66impl TryFrom<&str> for Xpub {
67    type Error = Error;
68
69    fn try_from(xpub: &str) -> Result<Self, Self::Error> {
70        let (xpub, original_format) = match &xpub[..4] {
71            "zpub" => (zpub_to_xpub(xpub)?, OriginalFormat::Zpub),
72            "ypub" => (ypub_to_xpub(xpub)?, OriginalFormat::Ypub),
73            "xpub" => (xpub.to_string(), OriginalFormat::Xpub),
74            starting => return Err(Error::NotXpub(starting.to_string())),
75        };
76
77        Ok(Self {
78            xpub: Bip32Xpub::from_str(&xpub)?,
79            original_format,
80        })
81    }
82}
83
84pub fn zpub_to_xpub(zpub: &str) -> Result<String, Error> {
85    let decoded = base58::decode_check(zpub)?;
86
87    // Replace version bytes (first 4 bytes) with xpub version
88    let mut xpub_bytes = [0u8; 78];
89    xpub_bytes[0..4].copy_from_slice(&[0x04, 0x88, 0xB2, 0x1E]); // xpub version bytes
90    xpub_bytes[4..].copy_from_slice(&decoded[4..]);
91
92    // Re-encode as xpub
93    let xpub = base58::encode_check(&xpub_bytes);
94
95    Ok(xpub)
96}
97
98pub fn ypub_to_xpub(ypub: &str) -> Result<String, Error> {
99    let decoded = base58::decode_check(ypub).map_err(Error::InvalidYpubDecode)?;
100
101    if decoded.len() != 78 {
102        return Err(Error::InvalidYpubLength(decoded.len()));
103    }
104
105    let mut xpub_bytes = [0u8; 78];
106    xpub_bytes.copy_from_slice(&decoded);
107    xpub_bytes[0..4].copy_from_slice(&[0x04, 0x88, 0xB2, 0x1E]); // xpub version bytes
108
109    // Re-encode as xpub
110    let xpub = base58::encode_check(&xpub_bytes);
111
112    Ok(xpub)
113}
114
115pub fn xpub_to_fingerprint(xpub: &Bip32Xpub) -> Result<Fingerprint, Error> {
116    let fingerprint = match xpub.parent_fingerprint.as_bytes() {
117        [0, 0, 0, 0] => xpub.fingerprint(),
118        _ => xpub.parent_fingerprint,
119    };
120    Ok(fingerprint)
121}
122
123pub fn xpub_str_to_fingerprint(xpub: &str) -> Result<Fingerprint, Error> {
124    let xpub = Bip32Xpub::from_str(xpub)?;
125    let fingerprint = xpub_to_fingerprint(&xpub)?;
126    Ok(fingerprint)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use pretty_assertions::assert_eq;
133
134    #[test]
135    fn test_zpub_to_xpub() {
136        let zpub = "zpub6rNrPrFwgm4wMBSysetK5tpLBS2HYT8TDKQA6amxFHKJUnQq8rNtc4JDfGYPbvF9wJyagPpG1Faqnfe3BB8XzKon8LwW9KkMWyAQ4RQHzB1";
137        let xpub_str = "xpub6CiKnWv7PPyyeb4kCwK4fidKqVjPfD9TP6MiXnzBVGZYNanNdY3mMvywcrdDc6wK82jyBSd95vsk26QujnJWPrSaPfYeyW7NyX37HHGtfQM";
138        let xpub = Xpub::try_from(zpub);
139
140        assert!(xpub.is_ok());
141        let xpub = xpub.unwrap();
142
143        assert_eq!(xpub.xpub.to_string(), xpub_str);
144    }
145
146    #[test]
147    fn test_ypub_to_xpub() {
148        let ypub = "ypub6X2aUb9NXbQM65mQy6oFECSB1CdSanwXHGTUcw7vt2LaAteuYtLoDQ6ao1fXDsenrZjgJKJyHvLypBBeo59cSKUivvwW8S6k7PVvQkVosxZ";
149        let xpub_str = "xpub6CCKAvUTNursEnaJ8k1d27LfqEUzeAx2N9wFqYE3W1xh7nqgJEBEbLSSmohwDxzsSvcsYqiQqFzRvta65Njbe5o84bF5YXHFqfSH2Dkhonm";
150        let xpub = Xpub::try_from(ypub);
151
152        assert!(xpub.is_ok());
153        let xpub = xpub.unwrap();
154
155        assert_eq!(xpub.xpub.to_string().as_str(), xpub_str);
156    }
157
158    #[test]
159    fn test_zpub_to_xpub_direct() {
160        // same test vector as test_zpub_to_xpub above
161        let zpub = "zpub6rNrPrFwgm4wMBSysetK5tpLBS2HYT8TDKQA6amxFHKJUnQq8rNtc4JDfGYPbvF9wJyagPpG1Faqnfe3BB8XzKon8LwW9KkMWyAQ4RQHzB1";
162        let expected = "xpub6CiKnWv7PPyyeb4kCwK4fidKqVjPfD9TP6MiXnzBVGZYNanNdY3mMvywcrdDc6wK82jyBSd95vsk26QujnJWPrSaPfYeyW7NyX37HHGtfQM";
163
164        let result = zpub_to_xpub(zpub).expect("should convert zpub to xpub");
165        assert_eq!(result, expected);
166    }
167
168    #[test]
169    fn test_ypub_to_xpub_direct() {
170        let ypub = "ypub6X2aUb9NXbQM65mQy6oFECSB1CdSanwXHGTUcw7vt2LaAteuYtLoDQ6ao1fXDsenrZjgJKJyHvLypBBeo59cSKUivvwW8S6k7PVvQkVosxZ";
171        let expected = "xpub6CCKAvUTNursEnaJ8k1d27LfqEUzeAx2N9wFqYE3W1xh7nqgJEBEbLSSmohwDxzsSvcsYqiQqFzRvta65Njbe5o84bF5YXHFqfSH2Dkhonm";
172
173        let result = ypub_to_xpub(ypub).expect("should convert ypub to xpub");
174        assert_eq!(result, expected);
175    }
176
177    #[test]
178    fn test_zpub_to_xpub_invalid() {
179        let invalid = "zpubINVALID";
180        let result = zpub_to_xpub(invalid);
181        assert!(result.is_err());
182    }
183
184    #[test]
185    fn test_ypub_to_xpub_invalid() {
186        let invalid = "ypubINVALID";
187        let result = ypub_to_xpub(invalid);
188        assert!(result.is_err());
189    }
190}