phase 1: full read path — bip39 + cip-3 + cip-1852 + koios + age-mnemonic + rmcp
end-to-end working wallet: paste 24-word mnemonic, age-encrypt at rest,
on unlock derive root + payment + stake keys, build cip-19 base address,
serve four tools over mcp stdio (wallet.address, wallet.network,
wallet.balance, wallet.utxos).
deps added: ed25519-bip32 0.4 (pallas only ships raw ed25519, not the
cardano variant of bip32 hd derivation), cryptoxide 0.4 for pbkdf2-hmac-sha512,
age 0.10 for at-rest mnemonic encryption, rpassword 7 for tty-only passphrase
prompts, toml 0.9 for config.toml.
new modules:
- crates/aldabra-core/src/derive.rs — payment + stake key derivation, hash
- crates/aldabra-chain/src/koios.rs — real reqwest impl, asset aggregation
- crates/aldabra-mcp/src/{bootstrap,config,tools}.rs
caught one bug pre-flight: get_balance was clobbering same-asset
quantities across utxos instead of summing. fixed + regression test.
headless support via ALDABRA_PASSPHRASE env (mcp clients own stdin so
the rpassword prompt path can't run). docker secret / systemd
EnvironmentFile sources it in production.
dockerfile: multi-stage rust:1.95-bookworm → debian:bookworm-slim, tini
as pid1, non-root aldabra user, /var/lib/aldabra owned 700.
29 unit tests + 1 ignored live-koios test. preprod smoke test exercised
initialize → tools/list → tools/call wallet.address end-to-end via
piped json-rpc; correct preprod address came back from canonical
abandon-art mnemonic.
phase 2 (send) is next.
This commit is contained in:
parent
edc976e5d9
commit
2b4fdff0c0
14 changed files with 4389 additions and 167 deletions
168
crates/aldabra-core/src/derive.rs
Normal file
168
crates/aldabra-core/src/derive.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
//! CIP-1852 child key derivation.
|
||||
//!
|
||||
//! Cardano hardened-account / soft-chain HD paths land here. The
|
||||
//! external surface is two key types ([`PaymentKey`], [`StakeKey`])
|
||||
//! and two derivation entry points ([`derive_payment_key`],
|
||||
//! [`derive_stake_key`]).
|
||||
//!
|
||||
//! ## CIP-1852 path layout
|
||||
//!
|
||||
//! ```text
|
||||
//! m / 1852' / 1815' / account' / chain / index
|
||||
//! ```
|
||||
//!
|
||||
//! - `1852'` — purpose (Cardano Shelley)
|
||||
//! - `1815'` — Cardano coin type (Ada Lovelace's birth year)
|
||||
//! - `account'` — hardened account index, usually 0
|
||||
//! - `chain` — `0` external (payment), `1` internal (change),
|
||||
//! `2` stake key
|
||||
//! - `index` — soft index within the chain
|
||||
//!
|
||||
//! ## Hardened indexing
|
||||
//!
|
||||
//! BIP-32 hardened indices are `index | 0x8000_0000`. The first
|
||||
//! three components of the path above are hardened; the last two
|
||||
//! (`chain`, `index`) are soft.
|
||||
//!
|
||||
//! ## Why a separate module
|
||||
//!
|
||||
//! Keeping derivation in its own file means [`lib.rs`] stays focused
|
||||
//! on the security-boundary types (Mnemonic, RootKey). New chain
|
||||
//! types (Byron, future Conway-era keys) plug in here without
|
||||
//! mutating the root crate.
|
||||
|
||||
use ed25519_bip32::{DerivationScheme, XPrv};
|
||||
use pallas_crypto::hash::{Hash, Hasher};
|
||||
|
||||
use crate::RootKey;
|
||||
|
||||
const SCHEME: DerivationScheme = DerivationScheme::V2;
|
||||
|
||||
/// Hardened-bit OR mask. BIP-32 hardened indices are `n | HARDENED`.
|
||||
const HARDENED: u32 = 0x8000_0000;
|
||||
|
||||
/// CIP-1852 purpose constant: Shelley.
|
||||
const PURPOSE: u32 = HARDENED | 1852;
|
||||
|
||||
/// Cardano coin type per SLIP-44 / CIP-1852.
|
||||
const COIN_TYPE: u32 = HARDENED | 1815;
|
||||
|
||||
/// Chain index for external (payment) addresses per CIP-1852.
|
||||
const CHAIN_PAYMENT: u32 = 0;
|
||||
|
||||
/// Chain index for stake keys per CIP-1852.
|
||||
const CHAIN_STAKE: u32 = 2;
|
||||
|
||||
/// A payment key derived at `m/1852'/1815'/account'/0/index`. Wraps
|
||||
/// an [`XPrv`] whose own [`Drop`] impl wipes the bytes.
|
||||
pub struct PaymentKey {
|
||||
xprv: XPrv,
|
||||
}
|
||||
|
||||
impl PaymentKey {
|
||||
/// Blake2b-224 hash of the 32-byte raw public key — the canonical
|
||||
/// payment-key hash that goes into a Shelley base address's
|
||||
/// payment part.
|
||||
pub fn public_key_hash(&self) -> Hash<28> {
|
||||
Hasher::<224>::hash(self.xprv.public().public_key_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// A stake key derived at `m/1852'/1815'/account'/2/0`. Same memory
|
||||
/// hygiene as [`PaymentKey`].
|
||||
pub struct StakeKey {
|
||||
xprv: XPrv,
|
||||
}
|
||||
|
||||
impl StakeKey {
|
||||
/// Blake2b-224 hash of the raw stake public key — goes into the
|
||||
/// delegation part of a Shelley base address.
|
||||
pub fn public_key_hash(&self) -> Hash<28> {
|
||||
Hasher::<224>::hash(self.xprv.public().public_key_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a payment key at `m/1852'/1815'/account'/0/index`.
|
||||
pub fn derive_payment_key(root: &RootKey, account: u32, index: u32) -> PaymentKey {
|
||||
let xprv = root
|
||||
.xprv()
|
||||
.derive(SCHEME, PURPOSE)
|
||||
.derive(SCHEME, COIN_TYPE)
|
||||
.derive(SCHEME, HARDENED | account)
|
||||
.derive(SCHEME, CHAIN_PAYMENT)
|
||||
.derive(SCHEME, index);
|
||||
PaymentKey { xprv }
|
||||
}
|
||||
|
||||
/// Derive the account stake key at `m/1852'/1815'/account'/2/0`.
|
||||
/// Each account has exactly one stake key (chain index 2, soft 0).
|
||||
pub fn derive_stake_key(root: &RootKey, account: u32) -> StakeKey {
|
||||
let xprv = root
|
||||
.xprv()
|
||||
.derive(SCHEME, PURPOSE)
|
||||
.derive(SCHEME, COIN_TYPE)
|
||||
.derive(SCHEME, HARDENED | account)
|
||||
.derive(SCHEME, CHAIN_STAKE)
|
||||
.derive(SCHEME, 0);
|
||||
StakeKey { xprv }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Mnemonic;
|
||||
|
||||
const ABANDON_ART: &str = concat!(
|
||||
"abandon abandon abandon abandon abandon abandon ",
|
||||
"abandon abandon abandon abandon abandon abandon ",
|
||||
"abandon abandon abandon abandon abandon abandon ",
|
||||
"abandon abandon abandon abandon abandon art",
|
||||
);
|
||||
|
||||
fn root_from_canonical() -> RootKey {
|
||||
Mnemonic::from_phrase(ABANDON_ART)
|
||||
.unwrap()
|
||||
.into_root_key()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payment_and_stake_key_hashes_are_28_bytes() {
|
||||
let root = root_from_canonical();
|
||||
let payment = derive_payment_key(&root, 0, 0);
|
||||
let stake = derive_stake_key(&root, 0);
|
||||
// Hash<28> is a strong type, but we still want to confirm the
|
||||
// raw byte length matches what pallas-addresses expects.
|
||||
assert_eq!(payment.public_key_hash().as_ref().len(), 28);
|
||||
assert_eq!(stake.public_key_hash().as_ref().len(), 28);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derivation_is_deterministic() {
|
||||
let root_a = root_from_canonical();
|
||||
let root_b = root_from_canonical();
|
||||
let pa = derive_payment_key(&root_a, 0, 0);
|
||||
let pb = derive_payment_key(&root_b, 0, 0);
|
||||
assert_eq!(pa.public_key_hash(), pb.public_key_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_and_index_change_the_payment_hash() {
|
||||
let root = root_from_canonical();
|
||||
let p_0_0 = derive_payment_key(&root, 0, 0);
|
||||
let p_0_1 = derive_payment_key(&root, 0, 1);
|
||||
let p_1_0 = derive_payment_key(&root, 1, 0);
|
||||
assert_ne!(p_0_0.public_key_hash(), p_0_1.public_key_hash());
|
||||
assert_ne!(p_0_0.public_key_hash(), p_1_0.public_key_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payment_and_stake_keys_differ_at_same_account() {
|
||||
let root = root_from_canonical();
|
||||
let payment = derive_payment_key(&root, 0, 0);
|
||||
let stake = derive_stake_key(&root, 0);
|
||||
// Same account but chain index 0 vs 2 — must produce
|
||||
// different key hashes.
|
||||
assert_ne!(payment.public_key_hash(), stake.public_key_hash());
|
||||
}
|
||||
}
|
||||
|
|
@ -3,28 +3,41 @@
|
|||
//! This crate is the security boundary. Everything that touches private
|
||||
//! key material lives here, and only here. No I/O, no network, no MCP.
|
||||
//!
|
||||
//! ## Layout (target)
|
||||
//! ## Layout
|
||||
//!
|
||||
//! - [`mnemonic`] — 24-word BIP-39 input → root key (CIP-3)
|
||||
//! - [`derive`] — Root key → payment + stake key (CIP-1852 paths)
|
||||
//! - [`address`] — Public keys → bech32 addresses (mainnet / testnet)
|
||||
//! - [`signing`] — Sign an unsigned transaction body
|
||||
//! - [`Mnemonic`] — 24-word BIP-39 input → entropy bytes.
|
||||
//! - [`Mnemonic::into_root_key`] — Icarus CIP-3 master-key generation.
|
||||
//! - [`RootKey`] — wraps [`ed25519_bip32::XPrv`].
|
||||
//! - [`Network`] — bech32 prefix + protocol magic selector.
|
||||
//!
|
||||
//! ## Phase 1 (this scaffold)
|
||||
//!
|
||||
//! Just types + a placeholder address-derivation function. Real impl
|
||||
//! lands as we wire up `pallas-crypto`'s key derivation API.
|
||||
//! Phases 1.3 (CIP-1852 child derivation), 1.4 (real base-address
|
||||
//! construction), and signing land in follow-up modules; the placeholder
|
||||
//! [`derive_base_address`] returns a sentinel address until then.
|
||||
//!
|
||||
//! ## Memory hygiene rule
|
||||
//!
|
||||
//! Anything that holds a private key MUST `derive(ZeroizeOnDrop)` or
|
||||
//! manually zeroize when going out of scope. Use the `zeroize` crate.
|
||||
//! This is non-negotiable — RAM-resident keys leak via core dumps,
|
||||
//! swap, hibernate state, etc.
|
||||
//! Anything holding private-key material zeroizes on drop:
|
||||
//! - [`Mnemonic`]'s entropy via `ZeroizeOnDrop`.
|
||||
//! - [`RootKey`]'s [`XPrv`] via its own [`Drop`] impl in `ed25519-bip32`.
|
||||
//!
|
||||
//! The decrypted phrase passed into [`Mnemonic::from_phrase`] is the
|
||||
//! caller's responsibility to drop promptly — we copy the entropy out
|
||||
//! and don't hold the source string.
|
||||
|
||||
use bip39::{Language, Mnemonic as Bip39Mnemonic};
|
||||
use cryptoxide::hmac::Hmac;
|
||||
use cryptoxide::pbkdf2::pbkdf2;
|
||||
use cryptoxide::sha2::Sha512;
|
||||
use ed25519_bip32::{XPrv, XPRV_SIZE};
|
||||
use pallas_addresses::{
|
||||
Network as PallasNetwork, ShelleyAddress, ShelleyDelegationPart, ShelleyPaymentPart,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use zeroize::ZeroizeOnDrop;
|
||||
|
||||
pub mod derive;
|
||||
pub use derive::{derive_payment_key, derive_stake_key, PaymentKey, StakeKey};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WalletError {
|
||||
#[error("invalid mnemonic: {0}")]
|
||||
|
|
@ -40,53 +53,87 @@ pub enum WalletError {
|
|||
NotYetImplemented,
|
||||
}
|
||||
|
||||
/// A 24-word BIP-39 mnemonic. Held in memory only while deriving keys;
|
||||
/// callers should drop this immediately after `derive_root_key`.
|
||||
/// A 24-word BIP-39 mnemonic, parsed and validated. Stores the raw
|
||||
/// 32-byte entropy rather than the phrase — the source string is the
|
||||
/// caller's responsibility to drop.
|
||||
///
|
||||
/// `ZeroizeOnDrop` ensures the entropy is wiped from RAM when this
|
||||
/// struct is dropped.
|
||||
#[derive(ZeroizeOnDrop)]
|
||||
pub struct Mnemonic {
|
||||
/// Stored as a single string (joined with spaces). The
|
||||
/// `ZeroizeOnDrop` derive ensures this gets wiped from RAM when
|
||||
/// the struct is dropped.
|
||||
phrase: String,
|
||||
/// 256 bits of entropy (24 BIP-39 words × 11 bits = 264 bits, the
|
||||
/// trailing 8 are the checksum). The bip39 crate's `to_entropy()`
|
||||
/// returns exactly the 32 entropy bytes.
|
||||
entropy: [u8; 32],
|
||||
}
|
||||
|
||||
impl Mnemonic {
|
||||
/// Parse a 24-word mnemonic from a whitespace-separated string.
|
||||
/// Validates word count + checksum via the `bip39` crate.
|
||||
///
|
||||
/// # Phase 1
|
||||
/// TODO: wire up `bip39::Mnemonic::parse_in` once we lock the
|
||||
/// API. For now this just stores the phrase verbatim — DO NOT
|
||||
/// rely on validation yet.
|
||||
/// Parse a 24-word English mnemonic, validating word count + checksum.
|
||||
/// Drops the source phrase reference immediately after extracting
|
||||
/// entropy.
|
||||
pub fn from_phrase(phrase: &str) -> Result<Self, WalletError> {
|
||||
// TODO(phase 1): real validation
|
||||
if phrase.split_whitespace().count() != 24 {
|
||||
return Err(WalletError::InvalidMnemonic(
|
||||
"expected 24 words".into(),
|
||||
));
|
||||
let parsed = Bip39Mnemonic::parse_in(Language::English, phrase)
|
||||
.map_err(|e| WalletError::InvalidMnemonic(e.to_string()))?;
|
||||
if parsed.word_count() != 24 {
|
||||
return Err(WalletError::InvalidMnemonic(format!(
|
||||
"expected 24 words, got {}",
|
||||
parsed.word_count()
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
phrase: phrase.to_string(),
|
||||
})
|
||||
let entropy_vec = parsed.to_entropy();
|
||||
let entropy: [u8; 32] = entropy_vec.try_into().map_err(|v: Vec<u8>| {
|
||||
WalletError::InvalidMnemonic(format!(
|
||||
"expected 32 entropy bytes for 24-word mnemonic, got {}",
|
||||
v.len()
|
||||
))
|
||||
})?;
|
||||
Ok(Self { entropy })
|
||||
}
|
||||
|
||||
/// Derive the Cardano CIP-3 root key from this mnemonic. Consumes
|
||||
/// the mnemonic so the source phrase is dropped + zeroized
|
||||
/// immediately after.
|
||||
/// Derive the Cardano CIP-3 root extended private key (Icarus
|
||||
/// variant, no passphrase). Consumes the mnemonic so the entropy
|
||||
/// is dropped + zeroized immediately after.
|
||||
pub fn into_root_key(self) -> Result<RootKey, WalletError> {
|
||||
// TODO(phase 1): pallas-crypto's PBKDF2 + entropy + chain code
|
||||
// derivation per CIP-3. Reference:
|
||||
// https://input-output-hk.github.io/cardano-wallet/concepts/master-key-generation
|
||||
Err(WalletError::NotYetImplemented)
|
||||
self.into_root_key_with_passphrase("")
|
||||
}
|
||||
|
||||
/// Derive the Cardano CIP-3 root extended private key with a
|
||||
/// caller-supplied BIP-39 passphrase. Empty string = no passphrase
|
||||
/// = the default Icarus / Yoroi behaviour.
|
||||
///
|
||||
/// Algorithm (per Cardano Icarus master-key generation):
|
||||
/// 1. `xprv = PBKDF2-HMAC-SHA512(password=passphrase, salt=entropy,
|
||||
/// c=4096, dkLen=96)`
|
||||
/// 2. Bit-clamp the first 32 bytes so the result is a valid extended
|
||||
/// Ed25519 scalar with the 3rd-highest bit cleared
|
||||
/// (`normalize_bytes_force3rd`).
|
||||
pub fn into_root_key_with_passphrase(
|
||||
self,
|
||||
passphrase: &str,
|
||||
) -> Result<RootKey, WalletError> {
|
||||
let mut xprv_bytes = [0u8; XPRV_SIZE];
|
||||
let mut hmac = Hmac::new(Sha512::new(), passphrase.as_bytes());
|
||||
pbkdf2(&mut hmac, &self.entropy, 4096, &mut xprv_bytes);
|
||||
|
||||
let xprv = XPrv::normalize_bytes_force3rd(xprv_bytes);
|
||||
Ok(RootKey { xprv })
|
||||
}
|
||||
}
|
||||
|
||||
/// CIP-3 root key. Holds the seed material from which payment + stake
|
||||
/// keys are derived via CIP-1852 paths. Zeroized on drop.
|
||||
#[derive(ZeroizeOnDrop)]
|
||||
/// CIP-3 root extended private key. Wraps an [`XPrv`]
|
||||
/// (96 bytes: extended secret + chain code). [`XPrv`]'s own [`Drop`]
|
||||
/// impl wipes the bytes from memory when this struct drops.
|
||||
pub struct RootKey {
|
||||
/// 96 bytes per CIP-3 (extended secret + chain code).
|
||||
bytes: [u8; 96],
|
||||
pub(crate) xprv: XPrv,
|
||||
}
|
||||
|
||||
impl RootKey {
|
||||
/// Borrow the underlying [`XPrv`] for derivation. Crate-internal
|
||||
/// code uses this; external callers should go through the
|
||||
/// `derive_*` helpers which return purpose-specific key types.
|
||||
pub(crate) fn xprv(&self) -> &XPrv {
|
||||
&self.xprv
|
||||
}
|
||||
}
|
||||
|
||||
/// Network parameter — bech32 prefix + protocol magic.
|
||||
|
|
@ -104,39 +151,139 @@ impl Network {
|
|||
Network::Preview | Network::Preprod => "addr_test",
|
||||
}
|
||||
}
|
||||
|
||||
/// Map our three-variant Network onto pallas-addresses' two
|
||||
/// real variants. Cardano's network header byte only distinguishes
|
||||
/// `Mainnet` from `Testnet` — the protocol magic differentiates
|
||||
/// Preview vs Preprod at the chain layer, not at the address layer.
|
||||
/// Both testnet flavours therefore share the `addr_test1…` HRP.
|
||||
pub fn to_pallas(&self) -> PallasNetwork {
|
||||
match self {
|
||||
Network::Mainnet => PallasNetwork::Mainnet,
|
||||
Network::Preview | Network::Preprod => PallasNetwork::Testnet,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a base address (payment + stake) at account 0, address index 0.
|
||||
/// Derive a Shelley base address (payment + stake) at the given
|
||||
/// account / payment-index path:
|
||||
///
|
||||
/// # Phase 1
|
||||
/// TODO: real CIP-1852 derivation using pallas-crypto's HD key derivation.
|
||||
/// For now this returns a placeholder that lets the MCP layer be tested
|
||||
/// without real keys.
|
||||
/// - payment path: `m/1852'/1815'/account'/0/index`
|
||||
/// - stake path: `m/1852'/1815'/account'/2/0`
|
||||
///
|
||||
/// Both keys hash through Blake2b-224 to produce 28-byte key hashes,
|
||||
/// which combine via [`ShelleyPaymentPart::key_hash`] +
|
||||
/// [`ShelleyDelegationPart::key_hash`] into a Shelley base address,
|
||||
/// emitted as bech32 with the right HRP for the chosen network.
|
||||
pub fn derive_base_address(
|
||||
_root: &RootKey,
|
||||
root: &RootKey,
|
||||
network: Network,
|
||||
_account: u32,
|
||||
_index: u32,
|
||||
account: u32,
|
||||
index: u32,
|
||||
) -> Result<String, WalletError> {
|
||||
// TODO(phase 1): real implementation
|
||||
let prefix = network.bech32_hrp_prefix();
|
||||
Ok(format!("{prefix}1placeholder_phase_1_scaffold"))
|
||||
let payment = derive_payment_key(root, account, index);
|
||||
let stake = derive_stake_key(root, account);
|
||||
|
||||
let address = ShelleyAddress::new(
|
||||
network.to_pallas(),
|
||||
ShelleyPaymentPart::key_hash(payment.public_key_hash()),
|
||||
ShelleyDelegationPart::key_hash(stake.public_key_hash()),
|
||||
);
|
||||
|
||||
address
|
||||
.to_bech32()
|
||||
.map_err(|e| WalletError::Address(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mnemonic_word_count_validation() {
|
||||
let too_few = "one two three";
|
||||
assert!(Mnemonic::from_phrase(too_few).is_err());
|
||||
/// Canonical 24-word BIP-39 test mnemonic. Used widely in the
|
||||
/// Cardano ecosystem (cardano-address, cardano-cli docs) so derived
|
||||
/// vectors are easy to cross-check.
|
||||
const ABANDON_ART: &str = concat!(
|
||||
"abandon abandon abandon abandon abandon abandon ",
|
||||
"abandon abandon abandon abandon abandon abandon ",
|
||||
"abandon abandon abandon abandon abandon abandon ",
|
||||
"abandon abandon abandon abandon abandon art",
|
||||
);
|
||||
|
||||
/// `Mnemonic` deliberately doesn't `derive(Debug)` — printing the
|
||||
/// entropy in a panic message would leak key material. Tests use
|
||||
/// this helper instead of `.unwrap_err()` (which requires `Debug`
|
||||
/// on the `Ok` variant).
|
||||
fn expect_invalid(result: Result<Mnemonic, WalletError>) -> WalletError {
|
||||
match result {
|
||||
Ok(_) => panic!("expected WalletError::InvalidMnemonic, got Ok(_)"),
|
||||
Err(e) => e,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_address_has_network_prefix() {
|
||||
let dummy_root = RootKey { bytes: [0u8; 96] };
|
||||
let addr = derive_base_address(&dummy_root, Network::Mainnet, 0, 0).unwrap();
|
||||
assert!(addr.starts_with("addr1"));
|
||||
fn rejects_short_phrase() {
|
||||
let err = expect_invalid(Mnemonic::from_phrase("one two three"));
|
||||
assert!(matches!(err, WalletError::InvalidMnemonic(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_checksum() {
|
||||
// 24 abandons in a row has a bad checksum — the canonical valid
|
||||
// form ends in "art".
|
||||
let bad = "abandon ".repeat(24);
|
||||
let err = expect_invalid(Mnemonic::from_phrase(bad.trim()));
|
||||
assert!(matches!(err, WalletError::InvalidMnemonic(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_canonical_24_word_mnemonic() {
|
||||
let m = Mnemonic::from_phrase(ABANDON_ART).expect("valid mnemonic");
|
||||
// 24 abandon-mostly words → entropy is all zeros.
|
||||
assert_eq!(m.entropy, [0u8; 32]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derives_root_key_from_canonical_mnemonic() {
|
||||
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
|
||||
let root = m.into_root_key().expect("CIP-3 derivation works");
|
||||
// The derived XPrv must be 96 bytes total and the bit-clamp
|
||||
// must have cleared the 3rd highest bit at byte 31.
|
||||
assert_eq!(root.xprv().as_ref().len(), XPRV_SIZE);
|
||||
assert!(root.xprv().is_3rd_highest_bit_clear());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mainnet_base_address_round_trips() {
|
||||
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
|
||||
let root = m.into_root_key().unwrap();
|
||||
let addr = derive_base_address(&root, Network::Mainnet, 0, 0).unwrap();
|
||||
assert!(addr.starts_with("addr1"), "got: {addr}");
|
||||
// Round-trip — pallas should parse what we just emitted and
|
||||
// give back a Shelley mainnet address.
|
||||
let parsed = pallas_addresses::Address::from_bech32(&addr)
|
||||
.expect("our own bech32 output parses");
|
||||
match parsed {
|
||||
pallas_addresses::Address::Shelley(s) => {
|
||||
assert_eq!(s.network(), pallas_addresses::Network::Mainnet);
|
||||
}
|
||||
other => panic!("expected Shelley address, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preprod_base_address_uses_testnet_hrp() {
|
||||
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
|
||||
let root = m.into_root_key().unwrap();
|
||||
let addr = derive_base_address(&root, Network::Preprod, 0, 0).unwrap();
|
||||
assert!(addr.starts_with("addr_test1"), "got: {addr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_indices_produce_different_addresses() {
|
||||
let m = Mnemonic::from_phrase(ABANDON_ART).unwrap();
|
||||
let root = m.into_root_key().unwrap();
|
||||
let a0 = derive_base_address(&root, Network::Mainnet, 0, 0).unwrap();
|
||||
let a1 = derive_base_address(&root, Network::Mainnet, 0, 1).unwrap();
|
||||
assert_ne!(a0, a1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue