aldabra/crates/aldabra-core/src/lib.rs
Cobb 7ea4c4cd33 phase 4.1-4.3: plutus script spend
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
2026-05-04 12:44:06 -07:00

316 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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);
}
}