kobe_primitives/style.rs
1//! Unified `DerivationStyle` trait and parse-error type.
2//!
3//! Several chains (`kobe-evm`, `kobe-svm`, `kobe-ton`) expose multiple
4//! derivation-path layouts to stay compatible with the hardware and
5//! software wallets users already own — `MetaMask` vs Ledger Live on EVM,
6//! Phantom vs Trust Wallet on Solana, Tonkeeper vs Ledger Live on TON.
7//!
8//! Without a shared contract each chain grew its own `DerivationStyle`
9//! enum with slightly different method names, its own
10//! `ParseDerivationStyleError`, and its own `FromStr` error message.
11//! This module collapses all three concerns into one
12//! [`DerivationStyle`] trait + one shared
13//! [`ParseDerivationStyleError`] so downstream callers and generic
14//! helpers (CLI rendering, property tests, agent tooling) can speak to
15//! any chain uniformly.
16//!
17//! # Contract
18//!
19//! Every chain's `DerivationStyle` enum is expected to:
20//!
21//! - Be `Copy + Eq + Hash + Default + Debug + Display` (trivial).
22//! - Implement [`FromStr`] with an exhaustive alias set; the returned
23//! error must be this module's [`ParseDerivationStyleError`].
24//! - Implement [`path`](DerivationStyle::path) returning the canonical
25//! BIP-32 / SLIP-10 path string for a given account index.
26//! - Implement [`name`](DerivationStyle::name) returning a stable,
27//! human-readable label used by CLI output.
28//! - Implement [`all`](DerivationStyle::all) returning every variant as
29//! a `'static` slice, so CLI / test code can enumerate styles without
30//! hand-maintaining a parallel list.
31
32use alloc::string::String;
33use core::fmt;
34use core::hash::Hash;
35use core::str::FromStr;
36
37/// Trait implemented by every chain's `DerivationStyle` enum.
38///
39/// See the crate-level documentation for the full cross-chain contract.
40pub trait DerivationStyle:
41 Copy
42 + Eq
43 + Hash
44 + Default
45 + fmt::Debug
46 + fmt::Display
47 + FromStr<Err = ParseDerivationStyleError>
48 + 'static
49{
50 /// Build the BIP-32 / SLIP-10 derivation path for account `index`.
51 ///
52 /// The returned `String` is owned because most call sites immediately
53 /// feed it into a `bip32` / `slip10` parser that needs `&str`; the
54 /// one-alloc-per-derivation cost is negligible against the
55 /// secp256k1 / ed25519 key math that follows.
56 fn path(self, index: u32) -> String;
57
58 /// Human-readable name for CLI output and help text.
59 ///
60 /// Kept stable across library versions so CLI users see identical
61 /// strings after upgrades.
62 fn name(self) -> &'static str;
63
64 /// Every variant of this enum in a `'static` slice.
65 ///
66 /// Powers CLI listing (`kobe evm styles`) and property tests that
67 /// want to iterate over the whole style set without hand-maintaining
68 /// a parallel list.
69 fn all() -> &'static [Self];
70}
71
72/// Error returned when [`FromStr`] fails on a chain's `DerivationStyle`.
73///
74/// Carries the chain name, the rejected input, and the full accepted
75/// token list (aliases included). The `Display` impl produces a single
76/// actionable diagnostic so CLI error output needs no further massaging.
77#[derive(Debug, Clone, PartialEq, Eq)]
78#[non_exhaustive]
79pub struct ParseDerivationStyleError {
80 chain: &'static str,
81 input: String,
82 accepted: &'static [&'static str],
83}
84
85impl ParseDerivationStyleError {
86 /// Construct a new error.
87 ///
88 /// `chain` is typically a short lowercase identifier (`"ethereum"`,
89 /// `"solana"`, `"ton"`); `accepted` must list every alias the chain's
90 /// [`FromStr`] implementation recognises, in the order they should
91 /// appear in the human-readable message.
92 #[inline]
93 #[must_use]
94 pub fn new(
95 chain: &'static str,
96 input: impl Into<String>,
97 accepted: &'static [&'static str],
98 ) -> Self {
99 Self {
100 chain,
101 input: input.into(),
102 accepted,
103 }
104 }
105
106 /// Chain that rejected the input (`"ethereum"`, `"solana"`, …).
107 #[inline]
108 #[must_use]
109 pub const fn chain(&self) -> &'static str {
110 self.chain
111 }
112
113 /// The user-supplied string that failed to parse.
114 #[inline]
115 #[must_use]
116 pub fn input(&self) -> &str {
117 &self.input
118 }
119
120 /// Full accepted alias list, in display order.
121 #[inline]
122 #[must_use]
123 pub const fn accepted(&self) -> &'static [&'static str] {
124 self.accepted
125 }
126}
127
128impl fmt::Display for ParseDerivationStyleError {
129 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130 write!(
131 f,
132 "invalid {} derivation style '{}', expected one of: {}",
133 self.chain,
134 self.input,
135 Joined(self.accepted),
136 )
137 }
138}
139
140#[cfg(feature = "std")]
141impl std::error::Error for ParseDerivationStyleError {}
142
143/// Inline `&str` joiner used by the `Display` impl to avoid allocating
144/// an intermediate `String` in `no_std + alloc` builds where
145/// `[&str]::join` is behind `std`.
146struct Joined<'a>(&'a [&'a str]);
147
148impl fmt::Display for Joined<'_> {
149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150 let mut first = true;
151 for s in self.0 {
152 if first {
153 first = false;
154 } else {
155 f.write_str(", ")?;
156 }
157 f.write_str(s)?;
158 }
159 Ok(())
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use alloc::string::ToString;
166
167 use super::*;
168
169 #[test]
170 fn error_display_lists_every_accepted_token() {
171 let err = ParseDerivationStyleError::new("ethereum", "bogus", &["standard", "live"]);
172 assert_eq!(
173 err.to_string(),
174 "invalid ethereum derivation style 'bogus', expected one of: standard, live"
175 );
176 }
177
178 #[test]
179 fn error_accessors_roundtrip() {
180 let err = ParseDerivationStyleError::new("solana", "bogus", &["a", "b"]);
181 assert_eq!(err.chain(), "solana");
182 assert_eq!(err.input(), "bogus");
183 assert_eq!(err.accepted(), &["a", "b"]);
184 }
185
186 #[test]
187 fn joined_empty_is_empty() {
188 assert_eq!(Joined(&[]).to_string(), "");
189 }
190
191 #[test]
192 fn joined_single_has_no_separator() {
193 assert_eq!(Joined(&["a"]).to_string(), "a");
194 }
195}