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}