aldabra/crates/aldabra-core/src/derive.rs
Sulkta 49986d2864 phase 4.5, 4.6, 3.6 close-out: stake delegation + multisig mint primitive
stake key + reward address (4.5):
- StakeKey::stake_address(network) — bech32 (`stake1...` mainnet,
  `stake_test1...` testnet) via pallas_addresses::StakeAddress::new
  (added to the fork in the same commit since the upstream tuple
  struct had no public constructor).
- StakeKey::xprv() — crate-internal accessor for signing.
- WalletInner now holds the stake_key alongside the payment_key.
- mcp tool wallet.stake.address surfaces the bech32.

stake delegation (4.6):
- new aldabra-core::stake module:
  - parse_pool_id(bech32) → Hash<28>
  - build_signed_stake_delegation(payment, stake, network, utxos,
    change_addr, pool_bech32, register_first, params) → signed cbor.
  - if register_first: prepends a StakeRegistration cert (consumes
    a 2 ADA deposit from inputs). otherwise just delegates.
  - signs with both payment_key (body witness) and stake_key (cert
    witness). reuses sign::add_witness for both — same body-hash
    ed25519 signing path regardless of CIP-1852 chain index.
- mcp tool wallet.stake.delegate: pool_id, register_first (defaults
  true). signs + submits.

3.6 close-out — wallet.mint.unsigned mcp tool:
- exposes the existing build_unsigned_mint with caller-supplied
  PolicySpec (json), so multi-sig / treasury flows can build through
  this wallet without it auto-signing. round-trip with
  wallet.sign_partial chain → wallet.submit_signed_tx.

depends on Sulkta-Coop/pallas@feat-aux-data which gained two more
patches in the same branch:
- StakeAddress::new public constructor.
- StagingTransaction::add_certificate / clear_certificates +
  Conway::build_conway_raw decode-and-plumb for certs (filling in the
  `certificates: None, // TODO` upstream).

mcp tools: 12 → 15 (wallet.stake.address, wallet.stake.delegate,
wallet.mint.unsigned).

79 → 84 unit tests. new coverage: stake address bech32 round-trip,
pool_id bech32 parse + reject-wrong-hrp, delegation tx with + without
registration (asserts cert count, witness count, cert variants).
fork tests grew: certificates_plumb_through_to_tx_body and
no_certificates_means_none.
2026-05-04 12:41:10 -07:00

227 lines
7.7 KiB
Rust

//! 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 {
/// Crate-internal — wrap an existing XPrv as a PaymentKey. Used
/// by the stake module to reuse the witness-append path with a
/// stake XPrv (the body-hash ed25519 signing logic is the same
/// regardless of CIP-1852 chain index).
pub(crate) fn from_xprv(xprv: XPrv) -> Self {
Self { xprv }
}
/// 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())
}
/// Borrow the underlying XPrv. Crate-internal — used by the `tx`
/// module to drive `pallas-wallet::PrivateKey::Extended` for
/// signing.
pub(crate) fn xprv(&self) -> &ed25519_bip32::XPrv {
&self.xprv
}
}
/// 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())
}
/// Reward / stake address (`stake1...` or `stake_test1...`)
/// bech32-encoded. This is the address you point at a stake pool
/// when delegating.
pub fn stake_address(
&self,
network: crate::Network,
) -> Result<String, crate::WalletError> {
use pallas_addresses::{StakeAddress, StakePayload};
let payload = StakePayload::Stake(self.public_key_hash());
let addr = StakeAddress::new(network.to_pallas(), payload);
addr.to_bech32()
.map_err(|e| crate::WalletError::Address(e.to_string()))
}
/// Borrow the underlying XPrv for signing — used by the cert /
/// delegation flow.
pub(crate) fn xprv(&self) -> &ed25519_bip32::XPrv {
&self.xprv
}
}
/// 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());
}
#[test]
fn stake_address_round_trips_through_pallas() {
let root = root_from_canonical();
let stake = derive_stake_key(&root, 0);
let mainnet_addr = stake.stake_address(crate::Network::Mainnet).unwrap();
assert!(
mainnet_addr.starts_with("stake1"),
"expected stake1... got {mainnet_addr}"
);
let testnet_addr = stake.stake_address(crate::Network::Preprod).unwrap();
assert!(
testnet_addr.starts_with("stake_test1"),
"expected stake_test1... got {testnet_addr}"
);
// Round-trip via pallas-addresses.
let parsed = pallas_addresses::Address::from_bech32(&mainnet_addr).unwrap();
match parsed {
pallas_addresses::Address::Stake(s) => {
assert_eq!(s.network(), pallas_addresses::Network::Mainnet);
}
other => panic!("expected Stake address, got {other:?}"),
}
}
}