From eda93f0ea2ff61afeb6bc7c11243b7c37eb7aff9 Mon Sep 17 00:00:00 2001 From: "x.qntx.eth" Date: Sat, 8 Aug 2026 15:39:53 +0800 Subject: [PATCH 1/2] feat: add Casper Network offline HD derivation (kobe-casper) Add dual-curve Casper support (default secp256k1 Ledger path, optional Ed25519 SLIP-10) with AccountHash addresses matching casper-types, CLI new/import --algo, umbrella feature, smoke KATs, docs, and CI/no_std wiring. --- .github/workflows/ci.yml | 2 + .github/workflows/publish.yml | 2 +- CHANGELOG.md | 12 + Cargo.lock | 10 + Cargo.toml | 1 + Justfile | 1 + Makefile | 1 + README.md | 12 +- crates/README.md | 9 +- crates/kobe-casper/Cargo.toml | 26 ++ crates/kobe-casper/src/address.rs | 155 ++++++++++ crates/kobe-casper/src/deriver.rs | 407 +++++++++++++++++++++++++ crates/kobe-casper/src/key_algo.rs | 101 ++++++ crates/kobe-casper/src/lib.rs | 73 +++++ crates/kobe-cli/src/commands/casper.rs | 107 +++++++ crates/kobe-cli/src/commands/mod.rs | 6 + crates/kobe-cli/src/commands/simple.rs | 2 +- crates/kobe-cli/src/main.rs | 1 + crates/kobe-primitives/src/derive.rs | 4 +- crates/kobe-primitives/src/lib.rs | 5 +- crates/kobe/Cargo.toml | 8 +- crates/kobe/src/lib.rs | 2 + crates/kobe/tests/cross_chain_smoke.rs | 12 + skills/kobe/SKILL.md | 35 ++- 24 files changed, 975 insertions(+), 19 deletions(-) create mode 100644 crates/kobe-casper/Cargo.toml create mode 100644 crates/kobe-casper/src/address.rs create mode 100644 crates/kobe-casper/src/deriver.rs create mode 100644 crates/kobe-casper/src/key_algo.rs create mode 100644 crates/kobe-casper/src/lib.rs create mode 100644 crates/kobe-cli/src/commands/casper.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 820b931..121f24b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,8 @@ jobs: run: cargo check -p kobe-nostr --target thumbv7m-none-eabi --no-default-features --features alloc - name: kobe-xrpl (no_std) run: cargo check -p kobe-xrpl --target thumbv7m-none-eabi --no-default-features --features alloc + - name: kobe-casper (no_std) + run: cargo check -p kobe-casper --target thumbv7m-none-eabi --no-default-features --features alloc - name: kobe (no_std) run: cargo check -p kobe --target thumbv7m-none-eabi --no-default-features --features alloc - name: kobe (no_std + all chains) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 79e090e..686ce0f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,6 +8,6 @@ jobs: publish: uses: qntx/workflows/.github/workflows/publish-crates.yml@main with: - packages: "kobe-primitives kobe-aptos kobe-btc kobe-evm kobe-svm kobe-cosmos kobe-tron kobe-spark kobe-fil kobe-ton kobe-sui kobe-xrpl kobe-nostr kobe kobe-cli" + packages: "kobe-primitives kobe-aptos kobe-btc kobe-evm kobe-svm kobe-cosmos kobe-tron kobe-spark kobe-fil kobe-ton kobe-sui kobe-xrpl kobe-nostr kobe-casper kobe kobe-cli" secrets: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dc36a9..6a7a406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this workspace are documented in this file. The format is ## [Unreleased] +### Added + +- **Casper Network (`kobe-casper`)** — offline HD derivation for CSPR + (SLIP-44 coin type `506`): + - Default algorithm **secp256k1** at Ledger path `m/44'/506'/0'/0/{i}` + - Alternate **Ed25519** (SLIP-10) at `m/44'/506'/0'/0'/{i}'` via `--algo ed25519` + - Primary address: Casper **AccountHash** (`account-hash-` + 64 hex); + preimage matches `casper-types` (`algorithm_name || 0x00 || raw_pubkey`) + - Library `CasperAccount` also exposes tagged public-key hex (`01…` / `02…`) + - CLI: `kobe casper` / `kobe cspr` (`new` / `import`) + - Umbrella feature: `casper` (included in `all-chains`) + ## [3.1.1] - 2026-08-08 ### Security diff --git a/Cargo.lock b/Cargo.lock index b7d3cbf..ef4556e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -650,6 +650,7 @@ version = "3.1.1" dependencies = [ "kobe-aptos", "kobe-btc", + "kobe-casper", "kobe-cosmos", "kobe-evm", "kobe-fil", @@ -686,6 +687,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kobe-casper" +version = "3.1.1" +dependencies = [ + "blake2", + "hex", + "kobe-primitives", +] + [[package]] name = "kobe-cli" version = "3.1.1" diff --git a/Cargo.toml b/Cargo.toml index 17e8a4c..a218f46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ kobe-tron = { version = "3.1", path = "crates/kobe-tron", default-fe kobe-aptos = { version = "3.1", path = "crates/kobe-aptos", default-features = false } kobe-nostr = { version = "3.1", path = "crates/kobe-nostr", default-features = false } kobe-xrpl = { version = "3.1", path = "crates/kobe-xrpl", default-features = false } +kobe-casper = { version = "3.1", path = "crates/kobe-casper", default-features = false } alloy-primitives = { version = "1.6.1", default-features = false, features = ["k256"] } base64 = { version = "0.23.1", default-features = false, features = ["alloc"] } diff --git a/Justfile b/Justfile index d05cc7c..7ca937c 100644 --- a/Justfile +++ b/Justfile @@ -38,6 +38,7 @@ check-no-std: cargo check -p kobe-sui --no-default-features --features alloc cargo check -p kobe-nostr --no-default-features --features alloc cargo check -p kobe-xrpl --no-default-features --features alloc + cargo check -p kobe-casper --no-default-features --features alloc cargo check -p kobe --no-default-features --features alloc cargo check -p kobe --no-default-features --features "alloc,all-chains" diff --git a/Makefile b/Makefile index 82bf62b..ee5a4b0 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,7 @@ check-no-std: cargo check -p kobe-sui --no-default-features --features alloc cargo check -p kobe-nostr --no-default-features --features alloc cargo check -p kobe-xrpl --no-default-features --features alloc + cargo check -p kobe-casper --no-default-features --features alloc cargo check -p kobe --no-default-features --features alloc cargo check -p kobe --no-default-features --features "alloc,all-chains" diff --git a/README.md b/README.md index 0f6e2a8..3792b5b 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ [rust-badge]: https://img.shields.io/badge/rust-edition%202024-orange.svg [rust-url]: https://doc.rust-lang.org/edition-guide/ -**`no_std`-compatible Rust toolkit for multi-chain HD wallet derivation — one BIP-39 seed, twelve networks, zero hand-written cryptography, cross-implementation KATs.** +**`no_std`-compatible Rust toolkit for multi-chain HD wallet derivation — one BIP-39 seed, thirteen networks, zero hand-written cryptography, cross-implementation KATs.** -Kobe derives standards-compliant accounts and addresses for Aptos, Bitcoin, Ethereum, Solana, Cosmos, Tron, Sui, TON, Filecoin, Spark, XRP Ledger, and Nostr (NIP-06 / NIP-19) from a single BIP-39 mnemonic. It layers thin wrappers around [`bip39`](https://docs.rs/bip39), [`bip32`](https://docs.rs/bip32), [`k256`](https://docs.rs/k256), and [`ed25519-dalek`](https://docs.rs/ed25519-dalek) on top of a unified `Wallet` + `Derive` trait surface (Bitcoin address/WIF encoding is local in `kobe-btc`, not the full `bitcoin` crate); every library crate builds under `no_std + alloc`, mnemonics and private keys wrap in `Zeroizing` and wipe on drop, and every chain's pipeline is pinned against independent reference implementations (bitcoinjs-lib, @ton/core, @noble/hashes, NIP-06 official vectors, ethanmarcuss/spark-address, …). +Kobe derives standards-compliant accounts and addresses for Aptos, Bitcoin, Ethereum, Solana, Cosmos, Tron, Sui, TON, Filecoin, Spark, XRP Ledger, Nostr (NIP-06 / NIP-19), and Casper from a single BIP-39 mnemonic. It layers thin wrappers around [`bip39`](https://docs.rs/bip39), [`bip32`](https://docs.rs/bip32), [`k256`](https://docs.rs/k256), and [`ed25519-dalek`](https://docs.rs/ed25519-dalek) on top of a unified `Wallet` + `Derive` trait surface (Bitcoin address/WIF encoding is local in `kobe-btc`, not the full `bitcoin` crate); every library crate builds under `no_std + alloc`, mnemonics and private keys wrap in `Zeroizing` and wipe on drop, and every chain's pipeline is pinned against independent reference implementations (bitcoinjs-lib, @ton/core, @noble/hashes, NIP-06 official vectors, ethanmarcuss/spark-address, casper-types AccountHash preimage, …). > **See also** [`signer`](https://github.com/qntx/signer) — the companion transaction-signing toolkit that consumes kobe's derived accounts via `Signer::from_derived`. @@ -61,6 +61,8 @@ kobe svm new # Solana (Phantom / Backpack / Solf kobe cosmos new # Cosmos Hub (`cosmos1…`) kobe aptos new # Aptos kobe sui new # Sui +kobe casper new # Casper (CSPR) +kobe casper new --algo ed25519 # Casper Ed25519 path kobe ton new # TON wallet v5r1 (UQ… non-bounceable) kobe ton new --bounceable # TON bounceable (EQ…), smart-contract style kobe ton new --testnet --workchain -1 # TON testnet masterchain @@ -91,7 +93,7 @@ kobe upgrade --force # reinstall even when up to date echo "abandon abandon ... about" | kobe -r evm import -m - ``` -Every chain subcommand accepts the shared flags `-w/--words`, `-c/--count`, `-p/--passphrase`, and `--qr` through a flattened `SimpleArgs` group, so ergonomics stay consistent across the 12 networks. Global `-r` / `--reveal` opts into printing mnemonics and private keys (default: hidden). +Every chain subcommand accepts the shared flags `-w/--words`, `-c/--count`, `-p/--passphrase`, and `--qr` through a flattened `SimpleArgs` group, so ergonomics stay consistent across the 13 networks. Global `-r` / `--reveal` opts into printing mnemonics and private keys (default: hidden). ### Library Usage @@ -169,13 +171,15 @@ println!("Mnemonic: {}", wallet.mnemonic()); | Sui | `kobe-sui` | Ed25519 (SLIP-10) | 784 | `m/44'/784'/{i}'/0'/0'` | `0x` + hex(`BLAKE2b-256(0x00 ‖ pubkey)`) | | TON | `kobe-ton` | Ed25519 (SLIP-10) | 607 | `m/44'/607'/{i}'` | wallet v5r1 (`UQ…` / `EQ…` / `0Q…` / …) | | Aptos | `kobe-aptos` | Ed25519 (SLIP-10) | 637 | `m/44'/637'/{i}'/0'/0'` | `0x` + hex(`SHA3-256(pubkey ‖ 0x00)`) | +| Casper | `kobe-casper` | secp256k1 / Ed25519 | 506 | `m/44'/506'/0'/0/{i}` ‡ | `account-hash-` + BLAKE2b-256 | \* Cosmos coin type defaults to `118`; Terra (`330`), Secret (`529`), Kava (`459`), and custom chains are selectable via `ChainConfig`. † Spark purpose `8797555` is Spark-specific (`SHA-256("spark")` truncated), not a BIP-44 assignment. +‡ Casper default is secp256k1 (Ledger); Ed25519 uses `m/44'/506'/0'/0'/{i}'`. AccountHash preimage is `algorithm_name || 0x00 || raw_pubkey` per `casper-types`. ## Design -- **12 chains** — Aptos, Bitcoin, Ethereum, Solana, Cosmos, Tron, Sui, TON, Filecoin, Spark, XRP Ledger, Nostr — one BIP-39 seed +- **13 chains** — Aptos, Bitcoin, Ethereum, Solana, Cosmos, Tron, Sui, TON, Filecoin, Spark, XRP Ledger, Nostr, Casper — one BIP-39 seed - **Mature crypto dependencies** — `bip39` for mnemonic ↔ entropy, `bip32` + `k256` for BIP-32 secp256k1 (via `kobe-primitives`), `ed25519-dalek` for SLIP-10 Ed25519; hashing via `sha2` / `sha3` / `blake2` / `ripemd`; encoding via `bech32` / `bs58` (Bitcoin addresses/WIF implemented in-tree and KAT-pinned) - **Unified derivation contract** — shared `Derive` trait with an associated `Account` type + shared `DerivationStyle` trait; every chain has typed public keys via `DerivedPublicKey`, one shared `DeriveError`, and one shared `ParseDerivationStyleError` - **Consistent entry points** — `derive` / `derive_with` / `derive_at` / `derive_at_with` across every chain (Bitcoin's structured path also available as `derive_structured`) diff --git a/crates/README.md b/crates/README.md index 4723c03..63c5425 100644 --- a/crates/README.md +++ b/crates/README.md @@ -16,6 +16,7 @@ | **[`kobe-spark`](kobe-spark/)** | [![crates.io][kobe-spark-crate]][kobe-spark-crate-url] [![docs.rs][kobe-spark-doc]][kobe-spark-doc-url] | Spark (Bitcoin L2) — identity keys + Bech32m `spark1…` addresses | | **[`kobe-xrpl`](kobe-xrpl/)** | [![crates.io][kobe-xrpl-crate]][kobe-xrpl-crate-url] [![docs.rs][kobe-xrpl-doc]][kobe-xrpl-doc-url] | XRP Ledger — classic `r`-addresses, secp256k1 | | **[`kobe-nostr`](kobe-nostr/)** | [![crates.io][kobe-nostr-crate]][kobe-nostr-crate-url] [![docs.rs][kobe-nostr-doc]][kobe-nostr-doc-url] | Nostr — NIP-06 key derivation, NIP-19 bech32 `nsec`/`npub` | +| **[`kobe-casper`](kobe-casper/)** | [![crates.io][kobe-casper-crate]][kobe-casper-crate-url] [![docs.rs][kobe-casper-doc]][kobe-casper-doc-url] | Casper — secp256k1 / Ed25519 HD + AccountHash | | **[`kobe-cli`](kobe-cli/)** | [![crates.io][kobe-cli-crate]][kobe-cli-crate-url] | CLI — generate, import, derive; `upgrade` via sh.qntx.fun | ## Dependency Graph @@ -35,7 +36,8 @@ kobe-cli ├── kobe-svm ── kobe-primitives/slip10 ├── kobe-ton ── kobe-primitives/slip10 ├── kobe-tron ── kobe-primitives/bip32 - └── kobe-xrpl ── kobe-primitives/bip32 + ├── kobe-xrpl ── kobe-primitives/bip32 + └── kobe-casper ── kobe-primitives/bip32 + slip10 ``` All chain crates consume key derivation through the wallet-level shortcuts @@ -65,6 +67,7 @@ The umbrella `kobe` crate provides fine-grained feature control: | `aptos` | | Aptos chain support (enables `slip10`) | | `xrpl` | | XRP Ledger chain support (enables `bip32`) | | `nostr` | | Nostr chain support (enables `bip32`) | +| `casper` | | Casper Network support (enables `bip32` + `slip10`) | | `all-chains` | | Enable all chain crates | [kobe-crate]: https://img.shields.io/crates/v/kobe.svg @@ -125,3 +128,7 @@ The umbrella `kobe` crate provides fine-grained feature control: [kobe-nostr-crate-url]: https://crates.io/crates/kobe-nostr [kobe-nostr-doc]: https://img.shields.io/docsrs/kobe-nostr.svg [kobe-nostr-doc-url]: https://docs.rs/kobe-nostr +[kobe-casper-crate]: https://img.shields.io/crates/v/kobe-casper.svg +[kobe-casper-crate-url]: https://crates.io/crates/kobe-casper +[kobe-casper-doc]: https://img.shields.io/docsrs/kobe-casper.svg +[kobe-casper-doc-url]: https://docs.rs/kobe-casper diff --git a/crates/kobe-casper/Cargo.toml b/crates/kobe-casper/Cargo.toml new file mode 100644 index 0000000..7e7cdca --- /dev/null +++ b/crates/kobe-casper/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "kobe-casper" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +readme = "../../README.md" +description = "Casper Network wallet for Kobe" +keywords = ["casper", "cspr", "wallet", "crypto", "no_std"] +categories = ["cryptography", "no-std"] + +[features] +default = ["std"] +std = ["alloc", "kobe-primitives/std", "hex/std"] +alloc = ["kobe-primitives/alloc", "hex/alloc"] + +[dependencies] +# Casper uses both secp256k1 (Ledger default path) and Ed25519 (casper-client default). +# Secrets zeroize via `kobe-primitives::DerivedAccount` / key types. +kobe-primitives = { workspace = true, features = ["bip32", "slip10"] } +blake2.workspace = true +hex.workspace = true + +[lints] +workspace = true diff --git a/crates/kobe-casper/src/address.rs b/crates/kobe-casper/src/address.rs new file mode 100644 index 0000000..e70c5f4 --- /dev/null +++ b/crates/kobe-casper/src/address.rs @@ -0,0 +1,155 @@ +//! Casper `PublicKey` tagging and `AccountHash` encoding. +//! +//! Encoding matches `casper-types` (`casper-node` `types` crate): +//! +//! - **Tagged public key** (serialization / display hex): tag byte + raw key. +//! - **`AccountHash`** preimage: `algorithm_name || 0x00 || raw_key` (no tag). + +#[cfg(feature = "alloc")] +use alloc::{format, string::String, vec::Vec}; + +use blake2::Blake2bVar; +use blake2::digest::{Update, VariableOutput}; +use kobe_primitives::DeriveError; + +/// Prefix applied to the hex-encoded `AccountHash` for display. +pub const ACCOUNT_HASH_PREFIX: &str = "account-hash-"; + +/// Casper serialization tag for Ed25519 public keys (`PublicKey::Ed25519`). +pub const ED25519_TAG: u8 = 0x01; + +/// Casper serialization tag for secp256k1 public keys (`PublicKey::Secp256k1`). +pub const SECP256K1_TAG: u8 = 0x02; + +/// Lowercase algorithm name used in the `AccountHash` preimage (Ed25519). +const ED25519_NAME: &[u8] = b"ed25519"; + +/// Lowercase algorithm name used in the `AccountHash` preimage (secp256k1). +const SECP256K1_NAME: &[u8] = b"secp256k1"; + +/// Format a 32-byte `AccountHash` digest as `account-hash-` + lowercase hex. +#[inline] +#[must_use] +pub fn format_account_hash(digest: &[u8; 32]) -> String { + format!("{ACCOUNT_HASH_PREFIX}{}", hex::encode(digest)) +} + +/// Tagged public-key hex (no `0x` prefix): `01 ‖ ed25519` or `02 ‖ secp`. +/// +/// `raw_key` must be the 32-byte Ed25519 key or 33-byte compressed secp256k1 +/// key (without the Casper algorithm tag). +#[inline] +#[must_use] +pub fn tagged_public_key_hex(tag: u8, raw_key: &[u8]) -> String { + let mut buf = Vec::with_capacity(1 + raw_key.len()); + buf.push(tag); + buf.extend_from_slice(raw_key); + hex::encode(buf) +} + +/// Compute the Casper `AccountHash` for an Ed25519 public key. +/// +/// Preimage: `b"ed25519" || 0x00 || pubkey` (32-byte raw key). +/// +/// # Errors +/// +/// Returns [`DeriveError::Crypto`] if `BLAKE2b` initialization or finalization +/// fails (should not occur for a fixed 32-byte output size). +pub fn account_hash_ed25519(pubkey: &[u8; 32]) -> Result<[u8; 32], DeriveError> { + account_hash_from_parts(ED25519_NAME, pubkey) +} + +/// Compute the Casper `AccountHash` for a compressed secp256k1 public key. +/// +/// Preimage: `b"secp256k1" || 0x00 || compressed_pubkey` (33-byte `SEC1`). +/// +/// # Errors +/// +/// Returns [`DeriveError::Crypto`] if `BLAKE2b` initialization or finalization +/// fails. +pub fn account_hash_secp256k1(compressed_pubkey: &[u8; 33]) -> Result<[u8; 32], DeriveError> { + account_hash_from_parts(SECP256K1_NAME, compressed_pubkey) +} + +/// Shared `AccountHash` construction: `name || 0x00 || raw_key` → `BLAKE2b`-256. +fn account_hash_from_parts(algorithm_name: &[u8], raw_key: &[u8]) -> Result<[u8; 32], DeriveError> { + let mut preimage = Vec::with_capacity(algorithm_name.len() + 1 + raw_key.len()); + preimage.extend_from_slice(algorithm_name); + preimage.push(0); + preimage.extend_from_slice(raw_key); + blake2b_256(&preimage) +} + +/// `BLAKE2b`-256 (empty key), matching Casper's `crypto::blake2b`. +fn blake2b_256(data: &[u8]) -> Result<[u8; 32], DeriveError> { + let mut hasher = + Blake2bVar::new(32).map_err(|e| DeriveError::Crypto(format!("blake2b init: {e}")))?; + hasher.update(data); + let mut out = [0u8; 32]; + hasher + .finalize_variable(&mut out) + .map_err(|e| DeriveError::Crypto(format!("blake2b finalize: {e}")))?; + Ok(out) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + reason = "unit tests panic on assertion failure" +)] +mod tests { + use super::*; + + /// Independent stdlib-equivalent vectors: preimage is `name||0x00||key`, + /// digest is `BLAKE2b`-256. Cross-checked with Python `hashlib.blake2b`. + #[test] + fn kat_account_hash_ed25519_fixed_key() { + let pk = hex::decode("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + .unwrap(); + let pk: [u8; 32] = pk.try_into().unwrap(); + let digest = account_hash_ed25519(&pk).unwrap(); + assert_eq!( + hex::encode(digest), + "5b1c945c6e0923bf4f8da320444804791eb60d70983c7c5756d8ef236c1fdece" + ); + assert_eq!( + format_account_hash(&digest), + "account-hash-5b1c945c6e0923bf4f8da320444804791eb60d70983c7c5756d8ef236c1fdece" + ); + assert_eq!( + tagged_public_key_hex(ED25519_TAG, &pk), + "010123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ); + } + + /// secp256k1 generator point (compressed) — independent `BLAKE2b` KAT. + #[test] + fn kat_account_hash_secp256k1_generator() { + let pk = hex::decode("0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + .unwrap(); + let pk: [u8; 33] = pk.try_into().unwrap(); + let digest = account_hash_secp256k1(&pk).unwrap(); + assert_eq!( + hex::encode(digest), + "86937931937ee0281e50806b94f8d4993e8869b0689dfa0a21d2946ab677183c" + ); + assert_eq!( + tagged_public_key_hex(SECP256K1_TAG, &pk), + "020279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + ); + } + + /// Preimage must include the null separator; wrong layout must not match. + #[test] + fn preimage_includes_null_separator() { + let pk = [0xab_u8; 32]; + let good = account_hash_ed25519(&pk).unwrap(); + // Tag-only layout (incorrect for AccountHash) must differ. + let mut wrong = Vec::with_capacity(33); + wrong.push(ED25519_TAG); + wrong.extend_from_slice(&pk); + let wrong_hash = blake2b_256(&wrong).unwrap(); + assert_ne!(good, wrong_hash); + } +} diff --git a/crates/kobe-casper/src/deriver.rs b/crates/kobe-casper/src/deriver.rs new file mode 100644 index 0000000..31399e4 --- /dev/null +++ b/crates/kobe-casper/src/deriver.rs @@ -0,0 +1,407 @@ +//! Casper account derivation from a unified wallet seed. + +use alloc::string::String; +use core::ops::Deref; + +use kobe_primitives::{ + DerivationStyle as _, Derive, DeriveError, DerivedAccount, DerivedPublicKey, Wallet, +}; + +use crate::address::{ + ED25519_TAG, SECP256K1_TAG, account_hash_ed25519, account_hash_secp256k1, format_account_hash, + tagged_public_key_hex, +}; +use crate::key_algo::KeyAlgo; + +/// A Casper-specific derived account. +/// +/// Wraps the unified [`DerivedAccount`] (`address` = `account-hash-…`) and +/// adds the signature algorithm plus the Casper **tagged** public-key hex +/// (`01…` / `02…`) used in serialization contexts. +/// +/// Implements `Deref` so shared accessors +/// (`address()`, `public_key_bytes()`, `private_key_hex()`, …) work directly. +#[derive(Clone)] +pub struct CasperAccount { + inner: DerivedAccount, + algo: KeyAlgo, + /// Lowercase hex of tag ‖ raw public key (no `0x` prefix). + public_key_hex: String, +} + +impl core::fmt::Debug for CasperAccount { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("CasperAccount") + .field("inner", &self.inner) + .field("algo", &self.algo) + .field("public_key_hex", &self.public_key_hex) + .finish() + } +} + +impl CasperAccount { + /// Signature algorithm used for this derivation. + #[inline] + #[must_use] + pub const fn algo(&self) -> KeyAlgo { + self.algo + } + + /// Casper tagged public-key hex (`01 ‖ ed25519` or `02 ‖ secp compressed`). + /// + /// No `0x` prefix; lowercase. + /// + /// **Name collision note:** this inherent method shadows + /// [`DerivedAccount::public_key_hex`] via `Deref`. Callers that need the + /// untagged raw curve key must use + /// `as_derived_account().public_key_hex()` or `public_key().to_hex()`. + #[inline] + #[must_use] + pub fn public_key_hex(&self) -> &str { + &self.public_key_hex + } + + /// Alias for [`Self::public_key_hex`] — preferred when reading code next + /// to untagged [`DerivedAccount::public_key_hex`]. + #[inline] + #[must_use] + pub fn tagged_public_key_hex(&self) -> &str { + &self.public_key_hex + } + + /// Formatted `AccountHash` (`account-hash-` + 64 hex). Alias for + /// [`DerivedAccount::address`]. + #[inline] + #[must_use] + pub fn account_hash(&self) -> &str { + self.inner.address() + } + + /// The underlying unified [`DerivedAccount`]. + #[inline] + #[must_use] + pub const fn as_derived_account(&self) -> &DerivedAccount { + &self.inner + } + + /// Consume and yield the underlying [`DerivedAccount`]. + #[inline] + #[must_use] + pub fn into_derived_account(self) -> DerivedAccount { + self.inner + } +} + +impl Deref for CasperAccount { + type Target = DerivedAccount; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl AsRef for CasperAccount { + #[inline] + fn as_ref(&self) -> &DerivedAccount { + &self.inner + } +} + +impl From for DerivedAccount { + #[inline] + fn from(account: CasperAccount) -> Self { + account.inner + } +} + +/// Casper address deriver from a unified wallet seed. +/// +/// Default algorithm is [`KeyAlgo::Secp256k1`] (Ledger path). Switch with +/// [`with_algo`](Self::with_algo) or per-call [`derive_with`](Self::derive_with). +#[derive(Debug)] +pub struct Deriver<'a> { + wallet: &'a Wallet, + algo: KeyAlgo, +} + +impl<'a> Deriver<'a> { + /// Create a deriver with the default algorithm ([`KeyAlgo::Secp256k1`]). + #[inline] + #[must_use] + pub const fn new(wallet: &'a Wallet) -> Self { + Self { + wallet, + algo: KeyAlgo::Secp256k1, + } + } + + /// Create a deriver locked to `algo` for [`Derive::derive`] / + /// [`Derive::derive_path`]. + #[inline] + #[must_use] + pub const fn with_algo(wallet: &'a Wallet, algo: KeyAlgo) -> Self { + Self { wallet, algo } + } + + /// Algorithm used by [`Derive::derive`] and [`Derive::derive_path`]. + #[inline] + #[must_use] + pub const fn algo(&self) -> KeyAlgo { + self.algo + } + + /// Derive at the default path for the deriver's algorithm. + /// + /// # Errors + /// + /// Returns an error if key derivation or `AccountHash` hashing fails. + #[inline] + pub fn derive(&self, index: u32) -> Result { + self.derive_with(self.algo, index) + } + + /// Derive at the default path for an explicit algorithm. + /// + /// # Errors + /// + /// Returns an error if key derivation or `AccountHash` hashing fails. + pub fn derive_with(&self, algo: KeyAlgo, index: u32) -> Result { + self.derive_at_with(&algo.path(index), algo) + } + + /// Derive at an arbitrary path using the deriver's stored algorithm for + /// tagging / `AccountHash`. + /// + /// Prefer [`derive_at_with`](Self::derive_at_with) when the path and + /// algorithm must be specified together. + /// + /// # Errors + /// + /// Returns an error if key derivation or `AccountHash` hashing fails. + #[inline] + pub fn derive_at(&self, path: &str) -> Result { + self.derive_at_with(path, self.algo) + } + + /// Derive at an arbitrary path with an explicit algorithm (encoding). + /// + /// The path must be valid for the curve of `algo` (BIP-32 for secp, + /// fully hardened SLIP-10 for Ed25519). + /// + /// # Errors + /// + /// Returns an error if key derivation or `AccountHash` hashing fails. + pub fn derive_at_with(&self, path: &str, algo: KeyAlgo) -> Result { + match algo { + KeyAlgo::Secp256k1 => self.derive_secp(path), + KeyAlgo::Ed25519 => self.derive_ed25519(path), + } + } + + fn derive_secp(&self, path: &str) -> Result { + let key = self.wallet.derive_secp256k1(path)?; + let compressed = key.compressed_pubkey(); + let digest = account_hash_secp256k1(&compressed)?; + let address = format_account_hash(&digest); + let public_key_hex = tagged_public_key_hex(SECP256K1_TAG, &compressed); + let sk = key.private_key_bytes(); + + let inner = DerivedAccount::new( + String::from(path), + sk, + DerivedPublicKey::Secp256k1Compressed(compressed), + address, + ); + + Ok(CasperAccount { + inner, + algo: KeyAlgo::Secp256k1, + public_key_hex, + }) + } + + fn derive_ed25519(&self, path: &str) -> Result { + let derived = self.wallet.derive_ed25519(path)?; + let pubkey_bytes = derived.public_key_bytes(); + let digest = account_hash_ed25519(&pubkey_bytes)?; + let address = format_account_hash(&digest); + let public_key_hex = tagged_public_key_hex(ED25519_TAG, &pubkey_bytes); + let sk_bytes = derived.private_key_bytes(); + + let inner = DerivedAccount::new( + String::from(path), + sk_bytes, + DerivedPublicKey::Ed25519(pubkey_bytes), + address, + ); + + Ok(CasperAccount { + inner, + algo: KeyAlgo::Ed25519, + public_key_hex, + }) + } +} + +impl Derive for Deriver<'_> { + type Account = CasperAccount; + type Error = DeriveError; + + fn derive(&self, index: u32) -> Result { + Deriver::derive(self, index) + } + + fn derive_path(&self, path: &str) -> Result { + self.derive_at(path) + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::indexing_slicing, + reason = "unit tests" +)] +mod tests { + use alloc::format; + use alloc::vec::Vec; + + use kobe_primitives::DeriveExt; + + use super::*; + use crate::address::{account_hash_ed25519, account_hash_secp256k1, format_account_hash}; + + /// Canonical BIP-39 test mnemonic (12 × `abandon` + `about`). + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + /// Locked HD KAT — abandon @ secp `m/44'/506'/0'/0/0`. + /// + /// Private key from workspace BIP-32 (`kobe-primitives`). `AccountHash` via + /// `casper-types` preimage (`b"secp256k1" || 0x00 || compressed_pk`), + /// independently re-checked with Python `hashlib.blake2b` over the + /// public key in `SECP0_TAGGED` (strip leading `02` tag byte). + const SECP0_PRIV: &str = "9c72144893c3ca5fa7299e65a7d7d6c41ab6a7add5f9860618324854d3c369d1"; + const SECP0_ADDR: &str = + "account-hash-e699fcd4904aa6617b2930c6d8995a6f301708b6a64621820a5896d92e2457b3"; + const SECP0_TAGGED: &str = + "020357f9e27d8125932c5e6fd52babb1a114bc89363f2f56c7860bb594f74523342b"; + + /// Locked HD KAT — abandon @ ed25519 `m/44'/506'/0'/0'/0'`. + /// + /// Private key matches independent Python SLIP-10 (`ed25519 seed` + path). + /// `AccountHash` re-checked with Python `hashlib.blake2b` over the untagged + /// public key (`ED0_TAGGED` without leading `01`). + const ED0_PRIV: &str = "619386127005778f66a68fa91518c0841f59495790bb796fc781ecdd54fe329a"; + const ED0_ADDR: &str = + "account-hash-356106f683840956a5bff75d011b236068ceccdf09d5c1a6a748c9355b635e08"; + const ED0_TAGGED: &str = "016a1585d8197fc14b1d8cc05d5351e5ba04810d466158a050494c799b776ff819"; + + fn test_wallet() -> Wallet { + Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap() + } + + /// Default deriver uses secp path and `AccountHash` address. + #[test] + fn default_algo_is_secp_path() { + let a = Deriver::new(&test_wallet()).derive(0).unwrap(); + assert_eq!(a.algo(), KeyAlgo::Secp256k1); + assert_eq!(a.path(), "m/44'/506'/0'/0/0"); + assert!(a.address().starts_with("account-hash-")); + assert_eq!(a.address().len(), "account-hash-".len() + 64); + assert!(a.public_key_hex().starts_with("02")); + assert_eq!(a.public_key_hex().len(), 2 + 66); // tag + 33-byte key hex + } + + /// `AccountHash` recomputed from public key bytes must match `address()`. + #[test] + fn account_hash_matches_pubkey_encoding_secp() { + let a = Deriver::new(&test_wallet()).derive(0).unwrap(); + let pk = match a.public_key() { + DerivedPublicKey::Secp256k1Compressed(b) => b, + other => panic!("expected compressed secp, got {other:?}"), + }; + let digest = account_hash_secp256k1(pk).unwrap(); + assert_eq!(a.address(), format_account_hash(&digest)); + assert_eq!(a.public_key_hex(), format!("02{}", hex::encode(pk))); + } + + #[test] + fn account_hash_matches_pubkey_encoding_ed25519() { + let a = Deriver::with_algo(&test_wallet(), KeyAlgo::Ed25519) + .derive(0) + .unwrap(); + assert_eq!(a.path(), "m/44'/506'/0'/0'/0'"); + let pk = match a.public_key() { + DerivedPublicKey::Ed25519(b) => b, + other => panic!("expected ed25519, got {other:?}"), + }; + let digest = account_hash_ed25519(pk).unwrap(); + assert_eq!(a.address(), format_account_hash(&digest)); + assert!(a.public_key_hex().starts_with("01")); + assert_eq!(a.public_key_hex().len(), 2 + 64); + } + + #[test] + fn kat_secp_abandon_index0() { + let a = Deriver::new(&test_wallet()).derive(0).unwrap(); + assert_eq!(a.path(), "m/44'/506'/0'/0/0"); + let sk = a.private_key_hex(); + assert_eq!(sk.as_str(), SECP0_PRIV); + assert_eq!(a.address(), SECP0_ADDR); + assert_eq!(a.public_key_hex(), SECP0_TAGGED); + } + + #[test] + fn kat_ed25519_abandon_index0() { + let a = Deriver::with_algo(&test_wallet(), KeyAlgo::Ed25519) + .derive(0) + .unwrap(); + assert_eq!(a.path(), "m/44'/506'/0'/0'/0'"); + let sk = a.private_key_hex(); + assert_eq!(sk.as_str(), ED0_PRIV); + assert_eq!(a.address(), ED0_ADDR); + assert_eq!(a.public_key_hex(), ED0_TAGGED); + } + + #[test] + fn kat_secp_abandon_index1_differs() { + let w = test_wallet(); + let d = Deriver::new(&w); + let a0 = d.derive(0).unwrap(); + let a1 = d.derive(1).unwrap(); + assert_ne!(a0.address(), a1.address()); + assert_eq!(a1.path(), "m/44'/506'/0'/0/1"); + } + + #[test] + fn derive_many_matches_individual() { + let w = test_wallet(); + let d = Deriver::new(&w); + let batch = d.derive_many(0, 3).unwrap(); + let single: Vec<_> = (0..3).map(|i| d.derive(i).unwrap()).collect(); + for (b, s) in batch.iter().zip(single.iter()) { + assert_eq!(b.address(), s.address()); + assert_eq!(b.path(), s.path()); + assert_eq!(b.public_key_hex(), s.public_key_hex()); + } + } + + #[test] + fn passphrase_changes_derivation() { + let w = Wallet::from_mnemonic(TEST_MNEMONIC, Some("TREZOR")).unwrap(); + assert_ne!( + Deriver::new(&test_wallet()).derive(0).unwrap().address(), + Deriver::new(&w).derive(0).unwrap().address(), + ); + } + + #[test] + fn ed_and_secp_addresses_differ() { + let w = test_wallet(); + let secp = Deriver::new(&w).derive(0).unwrap(); + let ed = Deriver::with_algo(&w, KeyAlgo::Ed25519).derive(0).unwrap(); + assert_ne!(secp.address(), ed.address()); + } +} diff --git a/crates/kobe-casper/src/key_algo.rs b/crates/kobe-casper/src/key_algo.rs new file mode 100644 index 0000000..02d4fe0 --- /dev/null +++ b/crates/kobe-casper/src/key_algo.rs @@ -0,0 +1,101 @@ +//! Casper signature algorithm / derivation-path style. + +use alloc::format; +use alloc::string::String; +use core::fmt; +use core::str::FromStr; + +use kobe_primitives::ParseDerivationStyleError; + +/// Signature algorithm and matching HD path layout for Casper. +/// +/// Implements [`kobe_primitives::DerivationStyle`] so CLI / generic helpers +/// can enumerate algorithms the same way they enumerate EVM styles. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +#[non_exhaustive] +pub enum KeyAlgo { + /// secp256k1 — Ledger / casper-cli default path `m/44'/506'/0'/0/{index}`. + /// + /// This is Kobe's **default** for Casper interop with hardware wallets. + #[default] + Secp256k1, + /// Ed25519 — SLIP-10 full-hardened path `m/44'/506'/0'/0'/{index}'`. + /// + /// Matches `casper-client keygen` default algorithm (different path + /// convention from Ledger secp). + Ed25519, +} + +/// Every variant — returned by [`kobe_primitives::DerivationStyle::all`]. +const ALL_ALGOS: &[KeyAlgo] = &[KeyAlgo::Secp256k1, KeyAlgo::Ed25519]; + +/// Tokens accepted by [`KeyAlgo::from_str`]. +const ACCEPTED_TOKENS: &[&str] = &["secp256k1", "secp", "ecdsa", "ed25519", "ed", "eddsa"]; + +impl kobe_primitives::DerivationStyle for KeyAlgo { + fn path(self, index: u32) -> String { + match self { + Self::Secp256k1 => format!("m/44'/506'/0'/0/{index}"), + Self::Ed25519 => format!("m/44'/506'/0'/0'/{index}'"), + } + } + + fn name(self) -> &'static str { + match self { + Self::Secp256k1 => "secp256k1", + Self::Ed25519 => "ed25519", + } + } + + fn all() -> &'static [Self] { + ALL_ALGOS + } +} + +impl fmt::Display for KeyAlgo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(::name(*self)) + } +} + +impl FromStr for KeyAlgo { + type Err = ParseDerivationStyleError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "secp256k1" | "secp" | "ecdsa" => Ok(Self::Secp256k1), + "ed25519" | "ed" | "eddsa" => Ok(Self::Ed25519), + _ => Err(ParseDerivationStyleError::new("casper", s, ACCEPTED_TOKENS)), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, reason = "unit tests")] +mod tests { + use kobe_primitives::DerivationStyle as _; + + use super::*; + + #[test] + fn paths() { + assert_eq!(KeyAlgo::Secp256k1.path(0), "m/44'/506'/0'/0/0"); + assert_eq!(KeyAlgo::Secp256k1.path(7), "m/44'/506'/0'/0/7"); + assert_eq!(KeyAlgo::Ed25519.path(0), "m/44'/506'/0'/0'/0'"); + assert_eq!(KeyAlgo::Ed25519.path(3), "m/44'/506'/0'/0'/3'"); + } + + #[test] + fn from_str_aliases() { + assert_eq!("secp256k1".parse::().unwrap(), KeyAlgo::Secp256k1); + assert_eq!("SECP".parse::().unwrap(), KeyAlgo::Secp256k1); + assert_eq!("ed25519".parse::().unwrap(), KeyAlgo::Ed25519); + assert_eq!("ed".parse::().unwrap(), KeyAlgo::Ed25519); + assert!("rsa".parse::().is_err()); + } + + #[test] + fn default_is_secp() { + assert_eq!(KeyAlgo::default(), KeyAlgo::Secp256k1); + } +} diff --git a/crates/kobe-casper/src/lib.rs b/crates/kobe-casper/src/lib.rs new file mode 100644 index 0000000..1ab01a9 --- /dev/null +++ b/crates/kobe-casper/src/lib.rs @@ -0,0 +1,73 @@ +//! Casper Network wallet utilities for Kobe. +//! +//! Offline HD derivation for **CSPR** (SLIP-44 coin type `506`) with dual +//! signature algorithms: +//! +//! | Algorithm | Default path | Curve | Kobe primitive | +//! | --- | --- | --- | --- | +//! | [`KeyAlgo::Secp256k1`] (default) | `m/44'/506'/0'/0/{i}` | secp256k1 | BIP-32 | +//! | [`KeyAlgo::Ed25519`] | `m/44'/506'/0'/0'/{i}'` | Ed25519 | SLIP-10 | +//! +//! # Address encoding +//! +//! The primary [`DerivedAccount::address`] is the Casper **`AccountHash`** +//! display form `account-hash-` + 64 lowercase hex digits. +//! +//! Per [`casper-types`](https://github.com/casper-network/casper-node) +//! `AccountHash::from_public_key`, the `BLAKE2b`-256 preimage is **not** the +//! tag-prefixed public-key serialization. It is: +//! +//! ```text +//! algorithm_name_ascii || 0x00 || raw_public_key_bytes +//! ``` +//! +//! where `algorithm_name` is the lowercase ASCII string `"secp256k1"` or +//! `"ed25519"`, and `raw_public_key_bytes` is the 33-byte compressed `SEC1` +//! secp256k1 key or the 32-byte Ed25519 key (no algorithm tag byte). +//! +//! The algorithm-tagged public-key hex used in Casper serialization / +//! CEP-57 contexts (`0x01 ‖ ed25519` or `0x02 ‖ secp compressed`) is +//! exposed separately on [`CasperAccount::public_key_hex`]. +//! +//! # Example +//! +//! ```no_run +//! use kobe_casper::{Deriver, KeyAlgo}; +//! use kobe_primitives::{Derive, Wallet}; +//! +//! # fn main() -> Result<(), Box> { +//! let wallet = Wallet::from_mnemonic( +//! "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", +//! None, +//! )?; +//! let account = Deriver::new(&wallet).derive(0)?; +//! assert!(account.address().starts_with("account-hash-")); +//! assert_eq!(account.algo(), KeyAlgo::Secp256k1); +//! # Ok(()) +//! # } +//! ``` + +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(feature = "alloc")] +extern crate alloc; + +#[cfg(feature = "alloc")] +mod address; +#[cfg(feature = "alloc")] +mod deriver; +#[cfg(feature = "alloc")] +mod key_algo; + +#[cfg(feature = "alloc")] +pub use address::{ + ACCOUNT_HASH_PREFIX, ED25519_TAG, SECP256K1_TAG, account_hash_ed25519, account_hash_secp256k1, + format_account_hash, tagged_public_key_hex, +}; +#[cfg(feature = "alloc")] +pub use deriver::{CasperAccount, Deriver}; +#[cfg(feature = "alloc")] +pub use key_algo::KeyAlgo; +pub use kobe_primitives::{ + DeriveError, DerivedAccount, DerivedPublicKey, ParseDerivationStyleError, +}; diff --git a/crates/kobe-cli/src/commands/casper.rs b/crates/kobe-cli/src/commands/casper.rs new file mode 100644 index 0000000..9189e4f --- /dev/null +++ b/crates/kobe-cli/src/commands/casper.rs @@ -0,0 +1,107 @@ +//! Casper Network wallet CLI commands. + +use clap::{Args, Subcommand, ValueEnum}; +use kobe::casper::{CasperAccount, Deriver, KeyAlgo}; +use kobe::{DerivationStyle as _, DeriveExt, Wallet}; + +use crate::commands::simple::SimpleArgs; +use crate::output::{self, AccountOutput, HdWalletOutput}; + +/// Casper wallet operations. +#[derive(Args, Debug)] +pub(crate) struct CasperCommand { + #[command(subcommand)] + command: CasperSubcommand, +} + +#[derive(Subcommand, Debug)] +enum CasperSubcommand { + /// Generate a new wallet (with mnemonic). + New { + #[command(flatten)] + args: CasperArgs, + }, + /// Import wallet from mnemonic phrase. + Import { + /// BIP-39 mnemonic (`-` = read one line from stdin; avoids shell history). + #[arg(short, long)] + mnemonic: String, + + #[command(flatten)] + args: CasperArgs, + }, +} + +/// CLI-facing mirror of [`kobe::casper::KeyAlgo`]. +#[derive(Debug, Clone, Copy, Default, ValueEnum)] +enum CliKeyAlgo { + /// secp256k1 — Ledger path `m/44'/506'/0'/0/{i}` (default). + #[default] + #[value(alias = "secp", alias = "ecdsa")] + Secp256k1, + /// Ed25519 — SLIP-10 path `m/44'/506'/0'/0'/{i}'`. + #[value(alias = "ed", alias = "eddsa")] + Ed25519, +} + +impl From for KeyAlgo { + fn from(value: CliKeyAlgo) -> Self { + match value { + CliKeyAlgo::Secp256k1 => Self::Secp256k1, + CliKeyAlgo::Ed25519 => Self::Ed25519, + } + } +} + +/// Casper-specific CLI flags layered on shared mnemonic / count options. +#[derive(Args, Debug, Clone)] +struct CasperArgs { + /// Signature algorithm / derivation path layout. + #[arg(long, value_enum, default_value_t = CliKeyAlgo::Secp256k1)] + algo: CliKeyAlgo, + + #[command(flatten)] + common: SimpleArgs, +} + +impl CasperCommand { + pub(crate) fn execute( + self, + json: bool, + reveal: bool, + ) -> Result<(), Box> { + let (mnemonic, args) = match self.command { + CasperSubcommand::New { args } => (None, args), + CasperSubcommand::Import { mnemonic, args } => (Some(mnemonic), args), + }; + let wallet = args.common.build_wallet(mnemonic.as_deref())?; + + let algo = KeyAlgo::from(args.algo); + let deriver = Deriver::with_algo(&wallet, algo); + let accounts = deriver.derive_many(0, args.common.count)?; + let out = build_hd(&wallet, algo, &accounts, reveal); + output::render_hd_wallet(&out, json, args.common.qr)?; + Ok(()) + } +} + +fn build_hd( + wallet: &Wallet, + algo: KeyAlgo, + accounts: &[CasperAccount], + reveal: bool, +) -> HdWalletOutput { + HdWalletOutput::new( + "casper", + wallet, + None, + None, + Some(algo.name()), + accounts + .iter() + .enumerate() + .map(|(i, a)| AccountOutput::from_derived(i, a.as_ref(), reveal)) + .collect(), + reveal, + ) +} diff --git a/crates/kobe-cli/src/commands/mod.rs b/crates/kobe-cli/src/commands/mod.rs index 2d153ce..8ed2422 100644 --- a/crates/kobe-cli/src/commands/mod.rs +++ b/crates/kobe-cli/src/commands/mod.rs @@ -2,6 +2,7 @@ mod aptos; mod bitcoin; +mod casper; mod cosmos; mod ethereum; mod filecoin; @@ -18,6 +19,7 @@ mod xrpl; pub(crate) use aptos::AptosCommand; pub(crate) use bitcoin::BitcoinCommand; +pub(crate) use casper::CasperCommand; use clap::{Parser, Subcommand}; pub(crate) use cosmos::CosmosCommand; pub(crate) use ethereum::EthereumCommand; @@ -102,6 +104,10 @@ pub(crate) enum Commands { #[command(name = "nostr")] Nostr(NostrCommand), + /// Casper Network wallet operations. + #[command(name = "casper", alias = "cspr")] + Casper(CasperCommand), + /// Mnemonic utilities (camouflage encrypt/decrypt). #[command(name = "mnemonic", alias = "mn")] Mnemonic(MnemonicCommand), diff --git a/crates/kobe-cli/src/commands/simple.rs b/crates/kobe-cli/src/commands/simple.rs index a4cbf05..5e6dd39 100644 --- a/crates/kobe-cli/src/commands/simple.rs +++ b/crates/kobe-cli/src/commands/simple.rs @@ -1,7 +1,7 @@ //! Shared command template for simple chains. //! //! Chains without network/address-type/style parameters (Aptos, Sui, Spark, -//! Filecoin, Tron, XRPL, TON, Nostr, …) all expose the same `new` / `import` +//! Filecoin, Tron, XRPL, TON, Nostr, Casper, …) all expose the same `new` / `import` //! surface. This module provides a single generic subcommand reused by each //! chain's thin wrapper, eliminating hundreds of lines of boilerplate. diff --git a/crates/kobe-cli/src/main.rs b/crates/kobe-cli/src/main.rs index 2a8bd86..4753128 100644 --- a/crates/kobe-cli/src/main.rs +++ b/crates/kobe-cli/src/main.rs @@ -48,6 +48,7 @@ fn run(cli: Cli) -> Result<(), Box> { Commands::Sui(cmd) => cmd.execute(json, reveal)?, Commands::Xrpl(cmd) => cmd.execute(json, reveal)?, Commands::Nostr(cmd) => cmd.execute(json, reveal)?, + Commands::Casper(cmd) => cmd.execute(json, reveal)?, Commands::Mnemonic(cmd) => cmd.execute(json, reveal)?, Commands::Upgrade(cmd) => cmd.execute(json)?, } diff --git a/crates/kobe-primitives/src/derive.rs b/crates/kobe-primitives/src/derive.rs index 9e52777..a30f99d 100644 --- a/crates/kobe-primitives/src/derive.rs +++ b/crates/kobe-primitives/src/derive.rs @@ -32,9 +32,9 @@ use crate::DeriveError; /// /// | Chain(s) | Variant | Length | /// | --- | --- | --- | -/// | `kobe-btc`, `kobe-cosmos`, `kobe-spark`, `kobe-xrpl` | [`Secp256k1Compressed`](Self::Secp256k1Compressed) | 33 B | +/// | `kobe-btc`, `kobe-cosmos`, `kobe-spark`, `kobe-xrpl`, `kobe-casper` (secp) | [`Secp256k1Compressed`](Self::Secp256k1Compressed) | 33 B | /// | `kobe-evm`, `kobe-fil`, `kobe-tron` | [`Secp256k1Uncompressed`](Self::Secp256k1Uncompressed) | 65 B | -/// | `kobe-svm`, `kobe-sui`, `kobe-aptos`, `kobe-ton` | [`Ed25519`](Self::Ed25519) | 32 B | +/// | `kobe-svm`, `kobe-sui`, `kobe-aptos`, `kobe-ton`, `kobe-casper` (ed25519) | [`Ed25519`](Self::Ed25519) | 32 B | /// | `kobe-nostr` | [`Secp256k1XOnly`](Self::Secp256k1XOnly) | 32 B | #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[non_exhaustive] diff --git a/crates/kobe-primitives/src/lib.rs b/crates/kobe-primitives/src/lib.rs index c4362ba..6c8641d 100644 --- a/crates/kobe-primitives/src/lib.rs +++ b/crates/kobe-primitives/src/lib.rs @@ -15,8 +15,9 @@ //! bip32::DerivedSecp256k1Key slip10::DerivedEd25519Key //! │ │ //! used by BTC / EVM / │ used by Solana / Sui / -//! Cosmos / Tron / Spark / │ Aptos / TON -//! Fil / XRPL / Nostr │ +//! Cosmos / Tron / Spark / │ Aptos / TON / +//! Fil / XRPL / Nostr / │ Casper (ed25519) +//! Casper (secp) │ //! └─────┬─────┘ //! ▼ //! DerivedAccount ─◄── every chain wraps this diff --git a/crates/kobe/Cargo.toml b/crates/kobe/Cargo.toml index e2fe752..99114c6 100644 --- a/crates/kobe/Cargo.toml +++ b/crates/kobe/Cargo.toml @@ -15,8 +15,8 @@ categories = ["cryptography::cryptocurrencies"] # (`mainstream`, `all-chains`) to keep binary size and compile time predictable. default = ["std"] -std = ["alloc", "kobe-primitives/std", "kobe-aptos?/std", "kobe-btc?/std", "kobe-evm?/std", "kobe-svm?/std", "kobe-cosmos?/std", "kobe-tron?/std", "kobe-spark?/std", "kobe-fil?/std", "kobe-ton?/std", "kobe-sui?/std", "kobe-nostr?/std", "kobe-xrpl?/std"] -alloc = ["kobe-primitives/alloc", "kobe-aptos?/alloc", "kobe-btc?/alloc", "kobe-evm?/alloc", "kobe-svm?/alloc", "kobe-cosmos?/alloc", "kobe-tron?/alloc", "kobe-spark?/alloc", "kobe-fil?/alloc", "kobe-ton?/alloc", "kobe-sui?/alloc", "kobe-nostr?/alloc", "kobe-xrpl?/alloc"] +std = ["alloc", "kobe-primitives/std", "kobe-aptos?/std", "kobe-btc?/std", "kobe-evm?/std", "kobe-svm?/std", "kobe-cosmos?/std", "kobe-tron?/std", "kobe-spark?/std", "kobe-fil?/std", "kobe-ton?/std", "kobe-sui?/std", "kobe-nostr?/std", "kobe-xrpl?/std", "kobe-casper?/std"] +alloc = ["kobe-primitives/alloc", "kobe-aptos?/alloc", "kobe-btc?/alloc", "kobe-evm?/alloc", "kobe-svm?/alloc", "kobe-cosmos?/alloc", "kobe-tron?/alloc", "kobe-spark?/alloc", "kobe-fil?/alloc", "kobe-ton?/alloc", "kobe-sui?/alloc", "kobe-nostr?/alloc", "kobe-xrpl?/alloc", "kobe-casper?/alloc"] rand = ["kobe-primitives/rand"] rand_core = ["kobe-primitives/rand_core"] camouflage = ["kobe-primitives/camouflage"] @@ -37,11 +37,12 @@ sui = ["dep:kobe-sui", "slip10"] aptos = ["dep:kobe-aptos", "slip10"] nostr = ["dep:kobe-nostr", "bip32"] xrpl = ["dep:kobe-xrpl", "bip32"] +casper = ["dep:kobe-casper", "bip32", "slip10"] # Preset: the three most requested chains (previous 1.x default). mainstream = ["btc", "evm", "svm"] # Preset: every chain crate. -all-chains = ["aptos", "btc", "evm", "svm", "cosmos", "tron", "spark", "fil", "ton", "sui", "nostr", "xrpl"] +all-chains = ["aptos", "btc", "evm", "svm", "cosmos", "tron", "spark", "fil", "ton", "sui", "nostr", "xrpl", "casper"] [dependencies] kobe-primitives = { workspace = true, features = ["alloc"] } @@ -57,6 +58,7 @@ kobe-ton = { workspace = true, optional = true } kobe-sui = { workspace = true, optional = true } kobe-nostr = { workspace = true, optional = true } kobe-xrpl = { workspace = true, optional = true } +kobe-casper = { workspace = true, optional = true } [package.metadata.docs.rs] all-features = true diff --git a/crates/kobe/src/lib.rs b/crates/kobe/src/lib.rs index 3d82b00..c73a68e 100644 --- a/crates/kobe/src/lib.rs +++ b/crates/kobe/src/lib.rs @@ -26,6 +26,8 @@ pub use kobe_aptos as aptos; #[cfg(feature = "btc")] pub use kobe_btc as btc; +#[cfg(feature = "casper")] +pub use kobe_casper as casper; #[cfg(feature = "cosmos")] pub use kobe_cosmos as cosmos; #[cfg(feature = "evm")] diff --git a/crates/kobe/tests/cross_chain_smoke.rs b/crates/kobe/tests/cross_chain_smoke.rs index ef883bc..0044e4f 100644 --- a/crates/kobe/tests/cross_chain_smoke.rs +++ b/crates/kobe/tests/cross_chain_smoke.rs @@ -132,6 +132,18 @@ mod smoke { assert_eq!(a.address(), "rHsMGQEkVNJmpGWs8XUBoTBiAAbwxZN5v3"); } + #[test] + fn casper_default_secp() { + let w = wallet(); + let a = kobe::casper::Deriver::new(&w).derive(0).unwrap(); + // Default KeyAlgo::Secp256k1 — KAT in kobe-casper. + assert_eq!( + a.address(), + "account-hash-e699fcd4904aa6617b2930c6d8995a6f301708b6a64621820a5896d92e2457b3" + ); + assert_eq!(a.algo(), kobe::casper::KeyAlgo::Secp256k1); + } + #[test] fn derive_many_agrees() { let w = wallet(); diff --git a/skills/kobe/SKILL.md b/skills/kobe/SKILL.md index dc33492..dbef61a 100644 --- a/skills/kobe/SKILL.md +++ b/skills/kobe/SKILL.md @@ -2,16 +2,16 @@ name: kobe description: >- Multi-chain cryptocurrency wallet CLI tool for generating, importing, and - managing HD wallets across 12 chains: Aptos, Bitcoin, Ethereum, Solana, Cosmos, - Tron, Sui, TON, Filecoin, Spark, XRP Ledger, and Nostr. Use when the user + managing HD wallets across 13 chains: Aptos, Bitcoin, Ethereum, Solana, Cosmos, + Tron, Sui, TON, Filecoin, Spark, XRP Ledger, Nostr, and Casper. Use when the user asks to create wallets, generate addresses, derive keys, import mnemonics, - produce NIP-19 `npub` / `nsec` identities, or perform any cryptocurrency - wallet operation. Supports JSON output via --json flag. + produce NIP-19 `npub` / `nsec` identities, Casper account-hash addresses, or + perform any cryptocurrency wallet operation. Supports JSON output via --json flag. --- # Kobe CLI — Multi-Chain HD Wallet Tool -`kobe` is a single binary CLI for generating and managing cryptocurrency wallets across **12 chains**: Aptos, Bitcoin, Ethereum, Solana, Cosmos, Tron, Sui, TON, Filecoin, Spark, XRP Ledger, and Nostr. It supports BIP-39 mnemonic generation, HD key derivation (BIP-32/44/49/84/86, SLIP-10, NIP-06), multiple derivation styles for hardware wallet compatibility, NIP-19 bech32 output for Nostr, and mnemonic camouflage encryption. +`kobe` is a single binary CLI for generating and managing cryptocurrency wallets across **13 chains**: Aptos, Bitcoin, Ethereum, Solana, Cosmos, Tron, Sui, TON, Filecoin, Spark, XRP Ledger, Nostr, and Casper. It supports BIP-39 mnemonic generation, HD key derivation (BIP-32/44/49/84/86, SLIP-10, NIP-06), multiple derivation styles for hardware wallet compatibility, NIP-19 bech32 output for Nostr, Casper AccountHash addresses, and mnemonic camouflage encryption. ## Installation @@ -74,6 +74,7 @@ The `--json` and `-r` / `--reveal` flags are **global**. When `--json` is set, a | Spark | `spark` | — | | XRP Ledger | `xrpl` | `xrp`, `ripple` | | Nostr | `nostr` | — | +| Casper | `casper` | `cspr` | | Mnemonic | `mnemonic` | `mn` | | Upgrade | `upgrade` | `update` | @@ -153,6 +154,15 @@ All four axes are independent. Key material is unaffected by `--testnet`, | ----------- | ----- | -------------------------------------------------- | --------- | | `--network` | `-n` | `mainnet`, `testnet`, `signet`, `regtest`, `local` | `mainnet` | +### Casper-specific flags + +| Flag | Short | Values | Default | +| -------- | ----- | --------------------------------------------- | ----------- | +| `--algo` | | `secp256k1` (aliases `secp`, `ecdsa`), `ed25519` (aliases `ed`, `eddsa`) | `secp256k1` | + +Default path is Ledger secp256k1 `m/44'/506'/0'/0/{i}`. Ed25519 uses +`m/44'/506'/0'/0'/{i}'`. Address is `account-hash-` + 64 hex. + ## Usage Examples ### Bitcoin @@ -284,6 +294,10 @@ kobe spark new --network local # sparkl1... # XRP Ledger kobe xrpl new + +# Casper (default secp256k1 Ledger path → account-hash-…) +kobe casper new +kobe casper new --algo ed25519 -c 3 ``` ### Nostr @@ -404,6 +418,7 @@ All errors in JSON mode return exit code 1 with: | Spark | 64-char hex string (compressed pubkey also provided) | | XRP Ledger | 64-char hex string | | Nostr | NIP-19 bech32 `nsec1…` (64-char hex also available; address is `npub1…`) | +| Casper | 64-char hex string (secp256k1 or Ed25519 secret; address is `account-hash-…`) | ## Derivation Path Reference @@ -497,6 +512,16 @@ Address: Bech32m-encoded compressed identity public key wrapped in a | ---------------------------- | ------------------------------------------------------------------ | | `m/44'/1237'/{account}'/0/0` | NIP-06: `{account}` is the `-c` index; pubkey is x-only / `npub1…` | +### Casper (SLIP-44 coin type 506) + +| Algorithm | Path Pattern | Notes | +| ----------- | ------------------------- | ----- | +| `secp256k1` | `m/44'/506'/0'/0/{i}` | Default (Ledger / casper-cli secp) | +| `ed25519` | `m/44'/506'/0'/0'/{i}'` | SLIP-10 full-hardened | + +Address: BLAKE2b-256 of `algorithm_name || 0x00 || raw_pubkey` formatted as +`account-hash-` + 64 lowercase hex (per `casper-types`). + ## Agent Best Practices 1. **Always use `--json`** for programmatic consumption to avoid ANSI escape codes. From 3cac7f1e5ad233bef22824fc18521fa6021a47a9 Mon Sep 17 00:00:00 2001 From: "x.qntx.eth" Date: Sat, 8 Aug 2026 15:42:21 +0800 Subject: [PATCH 2/2] fix(casper): allow clippy::panic in unit tests CI runs clippy with --all-targets; test-only panic! arms tripped -D warnings. --- crates/kobe-casper/src/deriver.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/kobe-casper/src/deriver.rs b/crates/kobe-casper/src/deriver.rs index 31399e4..ea50f60 100644 --- a/crates/kobe-casper/src/deriver.rs +++ b/crates/kobe-casper/src/deriver.rs @@ -262,6 +262,7 @@ impl Derive for Deriver<'_> { clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, + clippy::panic, reason = "unit tests" )] mod tests {