Skip to main content

causeway_sapling/
address.rs

1//! Sapling payment-address bech32 parse + encode.
2//!
3//! Wire format (Zcash protocol §5.6.4):
4//!
5//! ```text
6//! z-addr = bech32(<hrp>, raw43)
7//! raw43  = diversifier(11) ‖ pk_d(32)
8//! ```
9//!
10//! `hrp` (human-readable part) selects the network:
11//!
12//! - `zs`              — mainnet Sapling
13//! - `ztestsapling`    — public testnet Sapling
14//! - `zregtestsapling` — local regtest Sapling
15//!
16//! Sapling uses **bech32** (BCH-only, no `m` variant — that's bech32m
17//! used by unified addresses). We pin `bech32::Bech32` explicitly so
18//! a Sapling address string that's been mangled into bech32m fails
19//! decode rather than silently round-tripping through a different
20//! checksum.
21
22use thiserror::Error;
23
24/// Sapling network. Maps 1-1 to the bech32 hrp.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Network {
27    Mainnet,
28    Testnet,
29    Regtest,
30}
31
32impl Network {
33    /// Bech32 hrp for this network.
34    pub fn hrp(self) -> &'static str {
35        match self {
36            Network::Mainnet => "zs",
37            Network::Testnet => "ztestsapling",
38            Network::Regtest => "zregtestsapling",
39        }
40    }
41
42    /// Reverse lookup. Returns `None` for any other hrp (Orchard
43    /// unified, Sprout, transparent, etc.) — callers should fail
44    /// closed on a non-Sapling address rather than guess.
45    pub fn from_hrp(hrp: &str) -> Option<Self> {
46        match hrp {
47            "zs" => Some(Network::Mainnet),
48            "ztestsapling" => Some(Network::Testnet),
49            "zregtestsapling" => Some(Network::Regtest),
50            _ => None,
51        }
52    }
53}
54
55/// Decoded Sapling payment address. Always 43 bytes raw, regardless of network.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct SaplingAddress {
58    pub network: Network,
59    /// 11-byte diversifier ‖ 32-byte pk_d.
60    pub raw: [u8; 43],
61}
62
63#[derive(Debug, Error)]
64pub enum SaplingError {
65    #[error("bech32 decode: {0}")]
66    Bech32Decode(String),
67    #[error("bech32 encode: {0}")]
68    Bech32Encode(String),
69    #[error("hrp '{0}' is not a Sapling hrp (expected zs / ztestsapling / zregtestsapling)")]
70    UnknownHrp(String),
71    #[error("payload not 43 bytes: got {0}")]
72    PayloadLength(usize),
73    #[error("hrp parse: {0}")]
74    HrpParse(String),
75}
76
77/// Decode a Sapling bech32 address string into the network + raw 43-byte payload.
78pub fn decode_sapling_address(addr: &str) -> Result<SaplingAddress, SaplingError> {
79    let (hrp, words) = bech32::decode(addr)
80        .map_err(|e| SaplingError::Bech32Decode(e.to_string()))?;
81    let network = Network::from_hrp(hrp.as_str())
82        .ok_or_else(|| SaplingError::UnknownHrp(hrp.as_str().to_string()))?;
83    if words.len() != 43 {
84        return Err(SaplingError::PayloadLength(words.len()));
85    }
86    let mut raw = [0u8; 43];
87    raw.copy_from_slice(&words);
88    Ok(SaplingAddress { network, raw })
89}
90
91/// Encode a network + 43-byte raw payload back into a bech32 z-address.
92pub fn encode_sapling_address(network: Network, raw: &[u8]) -> Result<String, SaplingError> {
93    if raw.len() != 43 {
94        return Err(SaplingError::PayloadLength(raw.len()));
95    }
96    let hrp = bech32::Hrp::parse(network.hrp())
97        .map_err(|e| SaplingError::HrpParse(e.to_string()))?;
98    bech32::encode::<bech32::Bech32>(hrp, raw)
99        .map_err(|e| SaplingError::Bech32Encode(e.to_string()))
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    /// Real regtest vault address from a `bootstrap-operators-sapling`
107    /// run. Round-trip parse + encode must produce the byte-identical
108    /// string.
109    const REGTEST_VAULT: &str = "zregtestsapling1euldd485nn489mlc9qs7g0vt9em845mfzehcp8sverxtwczhyuwhu8jzexhk8z6w4xt2wld40jr";
110
111    #[test]
112    fn round_trip_regtest() {
113        let parsed = decode_sapling_address(REGTEST_VAULT).expect("decode");
114        assert_eq!(parsed.network, Network::Regtest);
115        assert_eq!(parsed.raw.len(), 43);
116        let re_encoded = encode_sapling_address(parsed.network, &parsed.raw).expect("encode");
117        assert_eq!(re_encoded, REGTEST_VAULT);
118    }
119
120    #[test]
121    fn unknown_hrp_rejected() {
122        // Transparent addresses use base58check, not bech32, but a
123        // contrived bech32 string with a non-sapling hrp should fail
124        // closed rather than parsing as some other address kind.
125        let bad = "ztestxyz1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqgmtkdv";
126        let res = decode_sapling_address(bad);
127        assert!(res.is_err(), "non-sapling hrp must be rejected");
128    }
129
130    #[test]
131    fn payload_length_enforced() {
132        // 43 bytes of zeros encodes as a syntactically valid Sapling
133        // bech32 address; a different length on the encode path must
134        // be rejected by the helper.
135        let res = encode_sapling_address(Network::Regtest, &[0u8; 42]);
136        assert!(matches!(res, Err(SaplingError::PayloadLength(42))));
137    }
138
139    #[test]
140    fn network_hrps() {
141        assert_eq!(Network::Mainnet.hrp(), "zs");
142        assert_eq!(Network::Testnet.hrp(), "ztestsapling");
143        assert_eq!(Network::Regtest.hrp(), "zregtestsapling");
144        assert_eq!(Network::from_hrp("zs"), Some(Network::Mainnet));
145        assert_eq!(Network::from_hrp("ztestsapling"), Some(Network::Testnet));
146        assert_eq!(Network::from_hrp("zregtestsapling"), Some(Network::Regtest));
147        assert_eq!(Network::from_hrp("u"), None);
148    }
149}