Skip to main content

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}