aldabra/crates/aldabra-core/src/lib.rs
Kayos 6443dcd858 feat(governance): wallet_drep_vote_cast + pallas voting_procedures patch
Phase 6, key-credentialed slice (script-DRep bridge for the DAO is the
remaining sub-arc).

## pallas-fork patch (Sulkta-Coop/pallas feat-aux-data HEAD 507fd9da)

Threads voting_procedures through StagingTransaction → conway::
build_conway_raw, mirroring the auxiliary_data + certificates patches.

- pallas-txbuilder/src/transaction/model.rs: voting_procedures field +
  builder methods .voting_procedures() / .clear_voting_procedures()
- pallas-txbuilder/src/conway.rs: VotingProcedures::decode_fragment on
  the way out, assigned to TransactionBody.voting_procedures
- BRANCH-NOTES.md: section 3 added documenting the new patch
- 2 new tests (round-trip + negative path) on the txbuilder side

aldabra Cargo.lock SHAs bumped to the new HEAD.

## aldabra-core/src/governance.rs

- VoteChoice enum (Yes/No/Abstain) with into_pallas() conversion
- build_signed_drep_vote_cast — assembles VotingProcedures CBOR
  (NonEmptyKeyValuePairs<Voter, NonEmptyKeyValuePairs<GovActionId,
  VotingProcedure>>) with this wallet's stake credential as a
  Voter::DRepKey, attaches via the new pallas API, dual-witness signs.
- Optional CIP-100 anchor on the vote.

## aldabra-mcp/src/tools.rs

- wallet_drep_vote_cast tool: gov_action_tx_hash + gov_action_index +
  vote (yes/no/abstain) + optional anchor.

What's still scope-of-Phase-6:
- Script-credentialed DRep voting (the DAO governor as DRep, with
  redeemer-driven authorization). Needs a different signing path
  since the voter is a script credential, not a key credential.
  Separate builder; defer until Sulkta wants to actually bridge.
2026-05-06 07:14:17 -07:00

442 lines
17 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::{Zeroize, ZeroizeOnDrop, Zeroizing};
pub mod cip68;
pub mod derive;
pub mod governance;
pub mod inspect;
pub mod metadata;
pub mod mint;
pub mod plutus;
pub mod plutus_cost_models;
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};
pub use inspect::{summarize_tx, AssetEntry, CertificateSummary, MintEntry, OutputSummary, TxSummary};
// 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 governance::{
build_signed_drep_deregistration, build_signed_drep_registration,
build_signed_drep_vote_cast, build_signed_vote_delegation, parse_drep_target,
DRepTarget, VoteChoice, DREP_REGISTRATION_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 {
/// Generate a fresh 24-word mnemonic from the system random source.
/// Returns the typed [`Mnemonic`] (entropy stored, zeroized on drop)
/// **and** the phrase string for one-time display to the user.
/// The phrase is wrapped in [`Zeroizing`] so the caller doesn't
/// have to remember to wipe it.
pub fn generate() -> Result<(Self, Zeroizing<String>), WalletError> {
let bip = Bip39Mnemonic::generate_in(Language::English, 24)
.map_err(|e| WalletError::InvalidMnemonic(format!("generate failed: {e}")))?;
// bip39's Display impl emits the space-separated phrase. Pull
// it out into our own owned + zeroized string before bip drops.
let phrase = Zeroizing::new(bip.to_string());
let entropy_vec = bip.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 }, phrase))
}
/// 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);
// `xprv_bytes` was moved into normalize_bytes_force3rd, but
// the stack slot can still hold a copy depending on calling
// conventions / inlining. Defensive zeroize.
// (M-1 audit fix.)
xprv_bytes.zeroize();
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
}
/// Construct from raw 96-byte XPrv (64-byte extended secret +
/// 32-byte chain code). Power-user import path — bypasses the
/// BIP-39 mnemonic flow entirely. Used when ingesting a key
/// generated by `cardano-cli address key-gen` or extracted from
/// a different Cardano wallet (the cnode `root.prv` file is the
/// canonical example).
///
/// Validates the byte count; the key itself is treated as
/// trusted input — this isn't a place to enforce semantic
/// correctness because any 96-byte sequence is a valid XPrv
/// from the type's perspective.
pub fn from_xprv_bytes(bytes: &[u8]) -> Result<Self, WalletError> {
if bytes.len() != XPRV_SIZE {
return Err(WalletError::Derivation(format!(
"xprv must be {XPRV_SIZE} bytes, got {}",
bytes.len()
)));
}
let mut buf = [0u8; XPRV_SIZE];
buf.copy_from_slice(bytes);
let xprv = XPrv::from_bytes_verified(buf)
.map_err(|e| WalletError::Derivation(format!("xprv verify: {e:?}")))?;
Ok(RootKey { xprv })
}
/// Construct from a bech32-encoded extended secret key —
/// specifically the `root_xsk1...` shape that Cardano CLI's
/// HD wallet tooling (cardano-address, cardano-hw-cli, the
/// IOG node `priv/wallet/<name>/root.prv` file) emits.
///
/// HRP must be exactly `root_xsk` — we refuse other extended-
/// key flavours (`acct_xsk`, `addr_xsk`, etc) so callers don't
/// accidentally import a derived child as if it were the root.
/// If you actually want to import an account-level or address-
/// level key, you'd be locking yourself out of CIP-1852
/// derivation; we'd rather force the explicit conversation.
pub fn from_root_xsk_bech32(s: &str) -> Result<Self, WalletError> {
let trimmed = s.trim();
let (hrp, data, variant) = bech32::decode(trimmed)
.map_err(|e| WalletError::Derivation(format!("bech32 decode: {e}")))?;
if hrp != "root_xsk" {
return Err(WalletError::Derivation(format!(
"expected root_xsk bech32, got {hrp:?}"
)));
}
if variant != bech32::Variant::Bech32 {
return Err(WalletError::Derivation(
"expected Bech32 (not Bech32m) for root_xsk".into(),
));
}
use bech32::FromBase32;
let bytes = Vec::<u8>::from_base32(&data)
.map_err(|e| WalletError::Derivation(format!("bech32 base32: {e}")))?;
Self::from_xprv_bytes(&bytes)
}
/// Encode the underlying XPrv bytes as `root_xsk1...` bech32.
/// Symmetric counterpart to [`from_root_xsk_bech32`]. Useful for
/// emitting the same shape that cardano-cli / cardano-address /
/// the IOG node's `priv/wallet/<name>/root.prv` files store.
/// **Sensitive output** — anyone with this string can spend the
/// wallet's funds.
pub fn to_root_xsk_bech32(&self) -> Result<String, WalletError> {
use bech32::ToBase32;
bech32::encode(
"root_xsk",
self.xprv.as_ref().to_base32(),
bech32::Variant::Bech32,
)
.map_err(|e| WalletError::Derivation(format!("bech32 encode: {e}")))
}
}
/// 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 generate_produces_24_word_phrase() {
let (mnemonic, phrase) = Mnemonic::generate().expect("generate");
assert_eq!(phrase.split_whitespace().count(), 24);
// Round-trip: re-parse the generated phrase, confirm we land on
// the same entropy.
let reparsed = Mnemonic::from_phrase(&phrase).expect("re-parse own output");
assert_eq!(reparsed.entropy, mnemonic.entropy);
}
#[test]
fn generate_produces_distinct_phrases() {
let (a, _phrase_a) = Mnemonic::generate().unwrap();
let (b, _phrase_b) = Mnemonic::generate().unwrap();
// Astronomically unlikely to collide; if this ever fails the
// RNG source is broken.
assert_ne!(a.entropy, b.entropy);
}
#[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);
}
}