Conway-era governance MCP tools, key-credentialed (script credentials deferred to Phase 6). aldabra-core/src/governance.rs (new ~500 LOC): - DRepTarget enum + parse_drep_target (handles bech32 drep1.../ drep_script1... + named 'abstain' / 'no_confidence') - build_signed_vote_delegation — Certificate::VoteDeleg(stake_cred, drep), reuses the dual-witness 2-pass-fee pattern from stake.rs. Optional register_first prepends StakeRegistration. - build_signed_drep_registration — Certificate::RegDRepCert with optional CIP-100/119 anchor + 500 ADA deposit - build_signed_drep_deregistration — Certificate::UnRegDRepCert with refund-aware change calc (deposit returns to wallet) - DREP_REGISTRATION_DEPOSIT_LOVELACE constant (500 ADA, mainnet) Made stake_key_as_payment_proxy pub(crate) so governance.rs can reuse the stake-key-as-witness trick. aldabra-mcp/src/tools.rs: - wallet_vote_delegate (drep + register_first) - wallet_drep_register (optional anchor_url + anchor_data_hash_hex) - wallet_drep_deregister (no args) 3 unit tests on parse_drep_target + DRepTarget→DRep round-trip. Phase 6 (vote_cast for DReps voting on Conway gov actions) blocked on extending Sulkta-Coop/pallas-txbuilder to thread voting_procedures through StagingTransaction (currently TODO at conway.rs:254). Same pattern as the aux_data + certificates patches already in the fork. Estimated ~300-500 LOC fork patch + ~400 LOC vote-cast builder. Surface to Sulkta before starting.
403 lines
15 KiB
Rust
403 lines
15 KiB
Rust
//! Stake registration + delegation flows.
|
|
//!
|
|
//! Cardano's reward system requires:
|
|
//! 1. The wallet's stake credential to be **registered** (one-time
|
|
//! 2 ADA deposit, refunded on deregistration).
|
|
//! 2. A **delegation** certificate pointing the stake credential at a
|
|
//! pool's keyhash.
|
|
//!
|
|
//! Both are `Certificate` entries in the tx body. Witnesses come from
|
|
//! both the payment key (for the body) and the stake key (for the
|
|
//! certificate-bound credential).
|
|
|
|
use bech32::FromBase32;
|
|
use pallas_codec::minicbor;
|
|
use pallas_crypto::hash::Hash;
|
|
use pallas_primitives::conway::{Certificate, StakeCredential};
|
|
use pallas_txbuilder::{BuildConway, Input, Output, StagingTransaction};
|
|
|
|
use crate::sign::add_witness;
|
|
use crate::tx::InputUtxo;
|
|
use crate::{Network, PaymentKey, ProtocolParams, StakeKey, WalletError};
|
|
|
|
/// Cardano stake-registration deposit (2 ADA, fixed by protocol since
|
|
/// Shelley). Refunded on deregistration.
|
|
pub const STAKE_KEY_DEPOSIT_LOVELACE: u64 = 2_000_000;
|
|
|
|
/// Two witnesses (payment + stake) instead of just one — used for fee
|
|
/// estimation. Both `register_first` paths sign with both keys, so the
|
|
/// witness overhead is constant.
|
|
const TWO_WITNESS_OVERHEAD_BYTES: u64 = 256;
|
|
|
|
/// Decode a `pool1...` bech32 pool ID into a 28-byte Hash.
|
|
pub fn parse_pool_id(bech32_str: &str) -> Result<Hash<28>, WalletError> {
|
|
let (hrp, data, _variant) = bech32::decode(bech32_str)
|
|
.map_err(|e| WalletError::Address(format!("bad pool bech32: {e}")))?;
|
|
if hrp != "pool" {
|
|
return Err(WalletError::Address(format!(
|
|
"expected hrp 'pool', got '{hrp}'"
|
|
)));
|
|
}
|
|
let bytes: Vec<u8> = Vec::<u8>::from_base32(&data)
|
|
.map_err(|e| WalletError::Address(format!("bad pool base32: {e}")))?;
|
|
if bytes.len() != 28 {
|
|
return Err(WalletError::Address(format!(
|
|
"pool id must be 28 bytes, got {}",
|
|
bytes.len()
|
|
)));
|
|
}
|
|
let mut out = [0u8; 28];
|
|
out.copy_from_slice(&bytes);
|
|
Ok(Hash::<28>::new(out))
|
|
}
|
|
|
|
fn parse_address(bech32: &str) -> Result<pallas_addresses::Address, WalletError> {
|
|
pallas_addresses::Address::from_bech32(bech32)
|
|
.map_err(|e| WalletError::Address(e.to_string()))
|
|
}
|
|
|
|
fn parse_tx_hash(hex_str: &str) -> Result<Hash<32>, WalletError> {
|
|
if hex_str.len() != 64 {
|
|
return Err(WalletError::Derivation(format!(
|
|
"expected 64-char hex tx_hash, got {}",
|
|
hex_str.len()
|
|
)));
|
|
}
|
|
let mut out = [0u8; 32];
|
|
for i in 0..32 {
|
|
out[i] = u8::from_str_radix(&hex_str[i * 2..i * 2 + 2], 16)
|
|
.map_err(|_| WalletError::Derivation(format!("invalid hex in tx_hash: {hex_str}")))?;
|
|
}
|
|
Ok(Hash::<32>::new(out))
|
|
}
|
|
|
|
fn network_id_for(network: Network) -> u8 {
|
|
match network {
|
|
Network::Mainnet => 1,
|
|
Network::Preview | Network::Preprod => 0,
|
|
}
|
|
}
|
|
|
|
/// Build + sign a stake delegation transaction. If `register_first` is
|
|
/// true, prepends a `StakeRegistration` certificate (one-time, costs
|
|
/// 2 ADA deposit). Otherwise just delegates.
|
|
///
|
|
/// The tx is signed by both the payment key (body witness) and the
|
|
/// stake key (cert witness). Returned CBOR is ready for submission.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn build_signed_stake_delegation(
|
|
payment_key: &PaymentKey,
|
|
stake_key: &StakeKey,
|
|
network: Network,
|
|
available_utxos: &[InputUtxo],
|
|
change_address_bech32: &str,
|
|
pool_id_bech32: &str,
|
|
register_first: bool,
|
|
params: &ProtocolParams,
|
|
) -> Result<Vec<u8>, WalletError> {
|
|
let pool_hash = parse_pool_id(pool_id_bech32)?;
|
|
let stake_pkh = stake_key.public_key_hash();
|
|
let credential = StakeCredential::AddrKeyhash(stake_pkh);
|
|
|
|
let mut cert_bytes_list: Vec<Vec<u8>> = Vec::new();
|
|
if register_first {
|
|
let reg = Certificate::StakeRegistration(credential.clone());
|
|
cert_bytes_list.push(
|
|
minicbor::to_vec(®)
|
|
.map_err(|e| WalletError::Derivation(format!("encode reg cert: {e}")))?,
|
|
);
|
|
}
|
|
let deleg = Certificate::StakeDelegation(credential, pool_hash);
|
|
cert_bytes_list.push(
|
|
minicbor::to_vec(&deleg)
|
|
.map_err(|e| WalletError::Derivation(format!("encode deleg cert: {e}")))?,
|
|
);
|
|
|
|
let change_addr = parse_address(change_address_bech32)?;
|
|
let network_id = network_id_for(network);
|
|
|
|
let deposit = if register_first {
|
|
STAKE_KEY_DEPOSIT_LOVELACE
|
|
} else {
|
|
0
|
|
};
|
|
|
|
// Lovelace need: deposit + fee + min_change.
|
|
let fee_pass1: u64 = 500_000;
|
|
let need = deposit
|
|
.checked_add(fee_pass1)
|
|
.and_then(|x| x.checked_add(params.min_utxo_lovelace))
|
|
.ok_or_else(|| WalletError::Derivation("amount overflow".into()))?;
|
|
|
|
let mut sorted: Vec<InputUtxo> = available_utxos.to_vec();
|
|
sorted.sort_by_key(|u| std::cmp::Reverse(u.lovelace));
|
|
let mut acc: u64 = 0;
|
|
let mut selected: Vec<InputUtxo> = Vec::new();
|
|
for u in sorted {
|
|
acc = acc.saturating_add(u.lovelace);
|
|
selected.push(u);
|
|
if acc >= need {
|
|
break;
|
|
}
|
|
}
|
|
if acc < need {
|
|
return Err(WalletError::Derivation(format!(
|
|
"insufficient funds for stake delegation: need {need} lovelace (deposit+fee+min_change), have {acc}"
|
|
)));
|
|
}
|
|
let total_in: u64 = selected.iter().map(|u| u.lovelace).sum();
|
|
|
|
// Aggregate input assets — we preserve them on change.
|
|
let mut input_assets: std::collections::BTreeMap<String, u64> = Default::default();
|
|
for u in &selected {
|
|
for (k, v) in &u.assets {
|
|
let entry = input_assets.entry(k.clone()).or_insert(0);
|
|
*entry = entry.saturating_add(*v);
|
|
}
|
|
}
|
|
|
|
let build_with_fee = |fee: u64,
|
|
change_lovelace: u64|
|
|
-> Result<StagingTransaction, WalletError> {
|
|
let mut staging = StagingTransaction::new();
|
|
for u in &selected {
|
|
let h = parse_tx_hash(&u.tx_hash_hex)?;
|
|
staging = staging.input(Input::new(h, u.output_index as u64));
|
|
}
|
|
let mut change_out = Output::new(change_addr.clone(), change_lovelace);
|
|
for (k, q) in &input_assets {
|
|
if *q == 0 {
|
|
continue;
|
|
}
|
|
if k.len() < 56 {
|
|
return Err(WalletError::Derivation(
|
|
"asset key shorter than 56 chars".into(),
|
|
));
|
|
}
|
|
let pol_hex = &k[..56];
|
|
let name_hex = &k[56..];
|
|
let mut policy_bytes = [0u8; 28];
|
|
for i in 0..28 {
|
|
policy_bytes[i] = u8::from_str_radix(&pol_hex[i * 2..i * 2 + 2], 16)
|
|
.map_err(|_| WalletError::Derivation("invalid policy hex in asset key".into()))?;
|
|
}
|
|
let policy = Hash::<28>::new(policy_bytes);
|
|
let mut name_bytes = Vec::with_capacity(name_hex.len() / 2);
|
|
for i in (0..name_hex.len()).step_by(2) {
|
|
name_bytes.push(
|
|
u8::from_str_radix(&name_hex[i..i + 2], 16)
|
|
.map_err(|_| WalletError::Derivation("invalid name hex".into()))?,
|
|
);
|
|
}
|
|
change_out = change_out
|
|
.add_asset(policy, name_bytes, *q)
|
|
.map_err(|e| WalletError::Derivation(format!("change add_asset: {e}")))?;
|
|
}
|
|
staging = staging.output(change_out);
|
|
for cb in &cert_bytes_list {
|
|
staging = staging.add_certificate(cb.clone());
|
|
}
|
|
staging = staging.fee(fee).network_id(network_id);
|
|
Ok(staging)
|
|
};
|
|
|
|
// Pass 1 — measure unsigned size.
|
|
let change_pass1 = total_in
|
|
.checked_sub(deposit + fee_pass1)
|
|
.ok_or_else(|| WalletError::Derivation("pass1: insufficient lovelace".into()))?;
|
|
let staging1 = build_with_fee(fee_pass1, change_pass1)?;
|
|
let unsigned = staging1
|
|
.build_conway_raw()
|
|
.map_err(|e| WalletError::Derivation(format!("conway build (pass1): {e}")))?
|
|
.tx_bytes
|
|
.0;
|
|
// Both delegation cases sign with two witnesses (payment + stake);
|
|
// registration doesn't add a third.
|
|
let est_signed = (unsigned.len() as u64) + TWO_WITNESS_OVERHEAD_BYTES;
|
|
let real_fee = params.min_fee_for_size(est_signed);
|
|
|
|
let token_change = !input_assets.is_empty();
|
|
let final_change = total_in
|
|
.checked_sub(deposit + real_fee)
|
|
.ok_or_else(|| WalletError::Derivation(format!(
|
|
"insufficient funds for fee: total_in={total_in} deposit={deposit} fee={real_fee}"
|
|
)))?;
|
|
if final_change < params.min_utxo_lovelace && token_change {
|
|
return Err(WalletError::Derivation(format!(
|
|
"insufficient ADA for token-bearing change: change={final_change}, min={}",
|
|
params.min_utxo_lovelace
|
|
)));
|
|
}
|
|
// No "merge change into fee" path here — the change output is
|
|
// necessary to balance the tx. Users with sub-min ADA after
|
|
// deposit+fee should top up first.
|
|
if final_change < params.min_utxo_lovelace {
|
|
return Err(WalletError::Derivation(format!(
|
|
"change after deposit+fee ({final_change}) below min utxo ({}). top up the wallet.",
|
|
params.min_utxo_lovelace
|
|
)));
|
|
}
|
|
|
|
let staging2 = build_with_fee(real_fee, final_change)?;
|
|
let built = staging2
|
|
.build_conway_raw()
|
|
.map_err(|e| WalletError::Derivation(format!("conway build (final): {e}")))?;
|
|
|
|
// Sign with payment key (body witness).
|
|
let payment_signed = add_witness(payment_key, &built.tx_bytes.0)?;
|
|
// Sign with stake key as well — the cert needs the stake credential's
|
|
// signature. add_witness reuses the same body-hash + ed25519
|
|
// signing path; just feeds in a different XPrv.
|
|
let stake_payment_proxy = stake_key_as_payment_proxy(stake_key);
|
|
let fully_signed = add_witness(&stake_payment_proxy, &payment_signed)?;
|
|
Ok(fully_signed)
|
|
}
|
|
|
|
/// Wrap a `StakeKey`'s XPrv into a `PaymentKey` so it can be passed
|
|
/// to `add_witness`. Both types are thin newtypes over `XPrv`; the
|
|
/// body-hash signing logic is identical regardless of which key
|
|
/// "role" the wallet considers the XPrv. Crate-internal helper —
|
|
/// callers use `build_signed_stake_delegation` end-to-end.
|
|
pub(crate) fn stake_key_as_payment_proxy(stake_key: &StakeKey) -> PaymentKey {
|
|
crate::derive::PaymentKey::from_xprv(stake_key.xprv().clone())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{Mnemonic, ProtocolParams};
|
|
|
|
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",
|
|
);
|
|
|
|
/// Synthesize a valid `pool1...` bech32 from a constant 28-byte
|
|
/// hash. Round-trips by construction; we never submit.
|
|
fn known_pool_bech32() -> String {
|
|
let bytes = [0xaau8; 28];
|
|
let data = bech32::ToBase32::to_base32(&bytes);
|
|
bech32::encode("pool", data, bech32::Variant::Bech32).unwrap()
|
|
}
|
|
|
|
fn payment_from_canonical() -> PaymentKey {
|
|
let root = Mnemonic::from_phrase(ABANDON_ART)
|
|
.unwrap()
|
|
.into_root_key()
|
|
.unwrap();
|
|
crate::derive::derive_payment_key(&root, 0, 0)
|
|
}
|
|
|
|
fn stake_from_canonical() -> StakeKey {
|
|
let root = Mnemonic::from_phrase(ABANDON_ART)
|
|
.unwrap()
|
|
.into_root_key()
|
|
.unwrap();
|
|
crate::derive::derive_stake_key(&root, 0)
|
|
}
|
|
|
|
fn change_address(network: Network) -> String {
|
|
let root = Mnemonic::from_phrase(ABANDON_ART)
|
|
.unwrap()
|
|
.into_root_key()
|
|
.unwrap();
|
|
crate::derive_base_address(&root, network, 0, 0).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn parse_pool_id_decodes_canonical() {
|
|
let h = parse_pool_id(&known_pool_bech32()).expect("pool id parses");
|
|
assert_eq!(h.as_ref().len(), 28);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_pool_id_rejects_wrong_hrp() {
|
|
// addr_test... is wrong hrp for a pool id.
|
|
let r = parse_pool_id(
|
|
"addr_test1qqqt0pru382hy9vjlsxv3ye02z50sfvt8xunscg5pgden77z73dpdfng2ctw2ekqplqgrljelz7h4dneac27nn3qx3rqqpavzj",
|
|
);
|
|
assert!(r.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn build_signed_stake_delegation_with_registration() {
|
|
use pallas_primitives::Fragment;
|
|
let payment = payment_from_canonical();
|
|
let stake = stake_from_canonical();
|
|
let change = change_address(Network::Preprod);
|
|
let utxos = vec![InputUtxo {
|
|
tx_hash_hex: "deadbeef".repeat(8),
|
|
output_index: 0,
|
|
lovelace: 100_000_000,
|
|
assets: Default::default(),
|
|
}];
|
|
let cbor = build_signed_stake_delegation(
|
|
&payment,
|
|
&stake,
|
|
Network::Preprod,
|
|
&utxos,
|
|
&change,
|
|
&known_pool_bech32(),
|
|
true,
|
|
&ProtocolParams::default(),
|
|
)
|
|
.expect("delegation tx builds + signs");
|
|
assert!(cbor.len() > 200);
|
|
|
|
let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor)
|
|
.expect("decode signed delegation cbor");
|
|
|
|
// Two certs: registration + delegation.
|
|
let certs = tx
|
|
.transaction_body
|
|
.certificates
|
|
.expect("certificates set")
|
|
.to_vec();
|
|
assert_eq!(certs.len(), 2);
|
|
assert!(matches!(certs[0], Certificate::StakeRegistration(_)));
|
|
assert!(matches!(certs[1], Certificate::StakeDelegation(_, _)));
|
|
|
|
// Two witnesses: payment + stake.
|
|
let witnesses = tx
|
|
.transaction_witness_set
|
|
.vkeywitness
|
|
.map(|w| w.to_vec().len())
|
|
.unwrap_or(0);
|
|
assert_eq!(witnesses, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn build_signed_stake_delegation_without_registration() {
|
|
use pallas_primitives::Fragment;
|
|
let payment = payment_from_canonical();
|
|
let stake = stake_from_canonical();
|
|
let change = change_address(Network::Preprod);
|
|
let utxos = vec![InputUtxo {
|
|
tx_hash_hex: "deadbeef".repeat(8),
|
|
output_index: 0,
|
|
lovelace: 100_000_000,
|
|
assets: Default::default(),
|
|
}];
|
|
let cbor = build_signed_stake_delegation(
|
|
&payment,
|
|
&stake,
|
|
Network::Preprod,
|
|
&utxos,
|
|
&change,
|
|
&known_pool_bech32(),
|
|
false,
|
|
&ProtocolParams::default(),
|
|
)
|
|
.expect("delegation-only tx builds");
|
|
let tx = pallas_primitives::conway::Tx::decode_fragment(&cbor).unwrap();
|
|
let certs = tx
|
|
.transaction_body
|
|
.certificates
|
|
.expect("certificates set")
|
|
.to_vec();
|
|
assert_eq!(certs.len(), 1, "only delegation cert when not registering");
|
|
assert!(matches!(certs[0], Certificate::StakeDelegation(_, _)));
|
|
}
|
|
}
|