Skip to main content

kobe_spark/
deriver.rs

1//! Spark address derivation from a unified wallet.
2//!
3//! Implements the Spark Protocol (<https://docs.spark.money>) identity-key
4//! derivation and Bech32m address encoding:
5//!
6//! - **Path**: `m/8797555'/{account}'/0'` — hardened BIP-32 secp256k1.
7//!   The purpose `8797555` is a Spark-specific constant derived from
8//!   `SHA-256("spark")`.
9//! - **Address**: `bech32m(HRP, proto_wrap(compressed_pubkey))` where
10//!   `proto_wrap` prepends the 2-byte pseudo-protobuf header `0x0a, 0x21`
11//!   (field 1, length-delimited, 33 B). HRP depends on [`Network`]:
12//!   - `spark` for mainnet
13//!   - `sparkt` for testnet
14//!   - `sparks` for signet
15//!   - `sparkrt` for regtest
16//!   - `sparkl` for local
17
18#[cfg(feature = "alloc")]
19use alloc::{format, string::String, vec::Vec};
20
21use bech32::{Bech32m, Hrp};
22use kobe_primitives::{Derive, DeriveError, DerivedAccount, DerivedPublicKey, Wallet};
23
24/// Spark protocol networks, each bound to a distinct Bech32 HRP.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
26#[non_exhaustive]
27pub enum Network {
28    /// Main Spark network (`spark1…`).
29    #[default]
30    Mainnet,
31    /// Testnet (`sparkt1…`).
32    Testnet,
33    /// Signet (`sparks1…`).
34    Signet,
35    /// Regtest (`sparkrt1…`).
36    Regtest,
37    /// Local development network (`sparkl1…`).
38    Local,
39}
40
41impl Network {
42    /// Bech32 human-readable prefix for this network.
43    #[must_use]
44    pub const fn hrp(self) -> &'static str {
45        match self {
46            Self::Mainnet => "spark",
47            Self::Testnet => "sparkt",
48            Self::Signet => "sparks",
49            Self::Regtest => "sparkrt",
50            Self::Local => "sparkl",
51        }
52    }
53
54    /// Human-readable display name.
55    #[must_use]
56    pub const fn name(self) -> &'static str {
57        match self {
58            Self::Mainnet => "mainnet",
59            Self::Testnet => "testnet",
60            Self::Signet => "signet",
61            Self::Regtest => "regtest",
62            Self::Local => "local",
63        }
64    }
65}
66
67/// Spark-specific BIP-32 purpose.
68///
69/// Matches the magic constant used by Spark SDKs; its decimal value
70/// `8_797_555` equals the last three bytes of `SHA-256("spark")`
71/// (`…863d73`) interpreted as a big-endian 24-bit integer, fitting BIP-43's
72/// 31-bit purpose field.
73///
74/// Reference: <https://docs.spark.money/wallets/identity-key-derivation>.
75pub const SPARK_PURPOSE: u32 = 8_797_555;
76
77/// Pseudo-protobuf wire header prepended to the 33-byte compressed pubkey
78/// before Bech32m encoding: field 1, wire type 2 (length-delimited).
79const PROTO_TAG: u8 = 0x0a;
80/// Length byte for a 33-byte compressed secp256k1 public key.
81const COMPRESSED_PUBKEY_LEN: u8 = 33;
82
83/// Spark address deriver from a unified wallet seed.
84///
85/// Derives Spark identity keys at path `m/8797555'/{account}'/0'` and
86/// encodes the resulting compressed public key as a Bech32m address for
87/// the configured [`Network`].
88#[derive(Debug)]
89pub struct Deriver<'a> {
90    /// Reference to the wallet for seed access.
91    wallet: &'a Wallet,
92    /// Network that determines the Bech32m HRP.
93    network: Network,
94}
95
96impl<'a> Deriver<'a> {
97    /// Create a new mainnet Spark deriver.
98    #[must_use]
99    pub const fn new(wallet: &'a Wallet) -> Self {
100        Self::with_network(wallet, Network::Mainnet)
101    }
102
103    /// Create a Spark deriver for the given network.
104    #[must_use]
105    pub const fn with_network(wallet: &'a Wallet, network: Network) -> Self {
106        Self { wallet, network }
107    }
108
109    /// Return the configured network.
110    #[inline]
111    #[must_use]
112    pub const fn network(&self) -> Network {
113        self.network
114    }
115
116    /// Derive a Spark identity account at the given account index.
117    ///
118    /// Uses the canonical Spark identity path
119    /// `m/{SPARK_PURPOSE}'/{index}'/0'` — see
120    /// <https://docs.spark.money/wallets/identity-key-derivation>.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if key derivation or address encoding fails.
125    #[inline]
126    pub fn derive(&self, index: u32) -> Result<DerivedAccount, DeriveError> {
127        self.derive_at(&format!("m/{SPARK_PURPOSE}'/{index}'/0'"))
128    }
129
130    /// Derive at an arbitrary BIP-32 path.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the path is malformed, derivation fails, or the
135    /// resulting pubkey cannot be Bech32m-encoded.
136    pub fn derive_at(&self, path: &str) -> Result<DerivedAccount, DeriveError> {
137        let key = self.wallet.derive_secp256k1(path)?;
138        let pubkey_bytes = key.compressed_pubkey();
139        let address = encode_spark_address(&pubkey_bytes, self.network)?;
140
141        Ok(DerivedAccount::new(
142            String::from(path),
143            key.private_key_bytes(),
144            DerivedPublicKey::Secp256k1Compressed(pubkey_bytes),
145            address,
146        ))
147    }
148}
149
150impl Derive for Deriver<'_> {
151    type Account = DerivedAccount;
152    type Error = DeriveError;
153
154    fn derive(&self, index: u32) -> Result<DerivedAccount, DeriveError> {
155        Deriver::derive(self, index)
156    }
157
158    fn derive_path(&self, path: &str) -> Result<DerivedAccount, DeriveError> {
159        self.derive_at(path)
160    }
161}
162
163/// Encode a compressed secp256k1 public key as a Spark Bech32m address.
164///
165/// Wraps the 33-byte pubkey in a 2-byte pseudo-protobuf header (field 1,
166/// wire type 2, length 33) and then Bech32m-encodes with the network's HRP.
167///
168/// # Errors
169///
170/// Returns [`DeriveError::AddressEncoding`] if HRP parsing or encoding fails
171/// (practically never, as the HRPs are compile-time constants).
172fn encode_spark_address(
173    compressed_pubkey: &[u8; 33],
174    network: Network,
175) -> Result<String, DeriveError> {
176    let mut payload = Vec::with_capacity(2 + compressed_pubkey.len());
177    payload.push(PROTO_TAG);
178    payload.push(COMPRESSED_PUBKEY_LEN);
179    payload.extend_from_slice(compressed_pubkey);
180
181    let hrp = Hrp::parse(network.hrp())
182        .map_err(|e| DeriveError::AddressEncoding(format!("spark: invalid HRP: {e}")))?;
183    bech32::encode::<Bech32m>(hrp, &payload)
184        .map_err(|e| DeriveError::AddressEncoding(format!("spark bech32m: {e}")))
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    /// Canonical BIP-39 test mnemonic (12 × `abandon` + `about`).
192    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
193
194    fn test_wallet() -> Wallet {
195        Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap()
196    }
197
198    /// Cross-verified with the independent `ethanmarcuss/spark-address`
199    /// Rust crate using its **published** mainnet test vector. This
200    /// locks the low-level `encode_spark_address` byte pipeline
201    /// (`0x0a || 0x21 || 33-byte pubkey` → Bech32m `spark1…`) against
202    /// an external reference implementation that is not derived from
203    /// kobe code.
204    #[test]
205    fn kat_encode_spark_address_matches_ethanmarcuss_reference() {
206        let pubkey_hex = "02894808873b896e21d29856a6d7bb346fb13c019739adb9bf0b6a8b7e28da53da";
207        let mut pubkey = [0u8; 33];
208        hex::decode_to_slice(pubkey_hex, &mut pubkey).unwrap();
209        let encoded = encode_spark_address(&pubkey, Network::Mainnet).unwrap();
210        assert_eq!(
211            encoded,
212            "spark1pgss9z2gpzrnhztwy8ffs44x67angma38sqewwddhxlsk65t0c5d5576quly2j"
213        );
214    }
215
216    /// End-to-end derivation + encoding KAT on the canonical
217    /// `abandon…about` mnemonic at Spark identity key path
218    /// `m/8797555'/{account}'/0'`. This test composes the spec-compliant
219    /// encoder (verified above against ethanmarcuss) with kobe's BIP-32
220    /// derivation, so any regression in the combined pipeline surfaces
221    /// here.
222    #[test]
223    fn kat_spark_mainnet_abandon_index0() {
224        let a = Deriver::new(&test_wallet()).derive(0).unwrap();
225        assert_eq!(a.path(), "m/8797555'/0'/0'");
226        assert_eq!(
227            a.address(),
228            "spark1pgssy6vty7krpze82ecm8j39gd35v35aqjjmhftc4culawsavkyh564uc6zmqs"
229        );
230    }
231
232    /// Testnet Bech32m HRP `sparkt` must yield a different address on the
233    /// same key material — confirms the HRP is the only difference while
234    /// the underlying 33-byte compressed pubkey is unchanged.
235    #[test]
236    fn testnet_and_mainnet_share_keys_not_address() {
237        let w = test_wallet();
238        let main = Deriver::new(&w).derive(0).unwrap();
239        let test = Deriver::with_network(&w, Network::Testnet)
240            .derive(0)
241            .unwrap();
242        assert!(main.address().starts_with("spark1"));
243        assert!(test.address().starts_with("sparkt1"));
244        assert_eq!(main.private_key_bytes(), test.private_key_bytes());
245        assert_eq!(main.public_key_bytes(), test.public_key_bytes());
246        assert_ne!(main.address(), test.address());
247    }
248
249    /// Every non-mainnet `Network` variant must map to its spec-defined
250    /// HRP, so `network()` matches the round-trip HRP of the emitted
251    /// address. Regression test for the `Network → Hrp` table.
252    #[test]
253    fn every_network_roundtrips_hrp() {
254        let w = test_wallet();
255        for net in [
256            Network::Mainnet,
257            Network::Testnet,
258            Network::Signet,
259            Network::Regtest,
260            Network::Local,
261        ] {
262            let a = Deriver::with_network(&w, net).derive(0).unwrap();
263            let (hrp, _) = bech32::decode(a.address()).unwrap();
264            assert_eq!(hrp.as_str(), net.hrp(), "HRP mismatch for {net:?}");
265        }
266    }
267}