new aldabra-core::plutus module:
- PlutusVersion enum (V1, V2, V3) → maps to ScriptKind on the
pallas-txbuilder side.
- PlutusExUnits (mem, steps) — public mirror of pallas's so callers
don't drag pallas types in. From<> impl converts internally.
- DEFAULT_EX_UNITS = (14M mem, 10B steps) — generous budget that
validates trivial validators ("always succeeds", simple equality);
real validators tune via the ex_units arg.
- MIN_COLLATERAL_LOVELACE = 5_000_000 (Conway protocol floor).
- build_signed_plutus_spend(payment, network, locked, script, redeemer,
witness_datum?, available_utxos, change_addr, payout_addr,
payout_lovelace, ex_units, params) → signed cbor.
- picks the largest wallet UTXO ≥ 5 ADA as collateral, errors out
if none qualifies.
- happy path: locked + collateral as inputs, payout + change as
outputs, script + redeemer + (optional witness) datum as
witnesses, wallet's payment key signs the body.
- reference inputs (4.2 expansion) and live ExUnits estimation
(4.4) are follow-ups.
- looks_like_script_address(bech32) bool sanity helper for callers
that want to filter by address kind before constructing a spend.
mcp tool wallet.script.spend: full args surface for one-shot
spend. plutus_version is a string ("v1"|"v2"|"v3"). ex_units optional.
84 → 88 unit tests. 15 → 16 mcp tools.
phase 4 status:
- 4.1 ☑ inline datum (already supported via Output::set_inline_datum
used by cip-68 mint)
- 4.2 ◐ reference input (txbuilder has the API; not yet exposed in
build_signed_plutus_spend — followup)
- 4.3 ☑ wallet.script.spend
- 4.4 ☐ ExUnits estimation — needs uplc / aiken integration, defer
- 4.5 ☑ stake key derivation
- 4.6 ☑ wallet.stake.delegate
316 lines
12 KiB
Rust
316 lines
12 KiB
Rust
//! aldabra core — keys, addresses, signing.
|
||
//!
|
||
//! 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
|
||
//!
|
||
//! - [`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.
|
||
//!
|
||
//! 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 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 cip68;
|
||
pub mod derive;
|
||
pub mod metadata;
|
||
pub mod mint;
|
||
pub mod plutus;
|
||
pub mod sign;
|
||
pub mod stake;
|
||
pub mod tx;
|
||
pub use cip68::{
|
||
build_cip68_datum_cbor, ft_asset_name, ref_nft_asset_name, user_nft_asset_name,
|
||
};
|
||
pub use derive::{derive_payment_key, derive_stake_key, PaymentKey, StakeKey};
|
||
// Stake address derivation lives directly on StakeKey — exported above.
|
||
pub use metadata::{build_cip25_aux_data, CIP25_LABEL};
|
||
pub use mint::{
|
||
build_signed_cip68_nft_mint, build_signed_mint, build_signed_mint_with_metadata,
|
||
build_unsigned_mint, PolicySpec,
|
||
};
|
||
pub use sign::add_witness;
|
||
pub use plutus::{
|
||
build_signed_plutus_spend, looks_like_script_address, PlutusExUnits, PlutusInput,
|
||
PlutusVersion, DEFAULT_EX_UNITS, MIN_COLLATERAL_LOVELACE,
|
||
};
|
||
pub use stake::{build_signed_stake_delegation, parse_pool_id, STAKE_KEY_DEPOSIT_LOVELACE};
|
||
pub use tx::{
|
||
build_signed_payment, build_signed_payment_with_assets, build_unsigned_payment,
|
||
build_unsigned_payment_with_assets, hex_decode, AssetSpec, InputUtxo, PaymentSummary,
|
||
ProtocolParams, UnsignedPayment,
|
||
};
|
||
|
||
#[derive(Debug, Error)]
|
||
pub enum WalletError {
|
||
#[error("invalid mnemonic: {0}")]
|
||
InvalidMnemonic(String),
|
||
|
||
#[error("derivation failed: {0}")]
|
||
Derivation(String),
|
||
|
||
#[error("address encoding failed: {0}")]
|
||
Address(String),
|
||
|
||
#[error("not yet implemented (phase 1 scaffold)")]
|
||
NotYetImplemented,
|
||
}
|
||
|
||
/// 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 {
|
||
/// 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 English mnemonic, validating word count + checksum.
|
||
/// Drops the source phrase reference immediately after extracting
|
||
/// entropy.
|
||
pub fn from_phrase(phrase: &str) -> Result<Self, WalletError> {
|
||
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()
|
||
)));
|
||
}
|
||
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 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> {
|
||
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 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 {
|
||
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.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||
pub enum Network {
|
||
Mainnet,
|
||
Preview,
|
||
Preprod,
|
||
}
|
||
|
||
impl Network {
|
||
pub fn bech32_hrp_prefix(&self) -> &'static str {
|
||
match self {
|
||
Network::Mainnet => "addr",
|
||
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 Shelley base address (payment + stake) at the given
|
||
/// account / payment-index path:
|
||
///
|
||
/// - 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,
|
||
network: Network,
|
||
account: u32,
|
||
index: u32,
|
||
) -> Result<String, WalletError> {
|
||
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::*;
|
||
|
||
/// 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 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);
|
||
}
|
||
}
|