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.
227 lines
7.7 KiB
Rust
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:?}"),
|
|
}
|
|
}
|
|
}
|