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.
This commit is contained in:
Sulkta 2026-05-06 07:14:17 -07:00
parent 77d4b2d0b5
commit eb48a5cf3e
4 changed files with 279 additions and 10 deletions

View file

@ -279,6 +279,197 @@ pub fn build_signed_drep_registration(
)
}
/// One Yes/No/Abstain vote on one Conway governance action.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VoteChoice {
Yes,
No,
Abstain,
}
impl VoteChoice {
fn into_pallas(self) -> pallas_primitives::conway::Vote {
use pallas_primitives::conway::Vote;
match self {
VoteChoice::Yes => Vote::Yes,
VoteChoice::No => Vote::No,
VoteChoice::Abstain => Vote::Abstain,
}
}
}
/// Build + sign a DRep vote-cast tx. The wallet's stake credential
/// signs as the DRep voter — must already be registered as a DRep
/// (via `build_signed_drep_registration` or a separate flow) for the
/// vote to count on chain.
///
/// `gov_action_tx_hash_hex` + `gov_action_index` identify the Conway
/// governance action to vote on (look these up via Koios / chain
/// passthrough tools — `chain_governance_actions` will land alongside
/// this when wired). `anchor_url` + `anchor_data_hash_hex` are optional
/// per-vote rationale (CIP-100). Pass `None` for both when omitting.
#[allow(clippy::too_many_arguments)]
pub fn build_signed_drep_vote_cast(
payment_key: &PaymentKey,
stake_key: &StakeKey,
network: Network,
available_utxos: &[InputUtxo],
change_address_bech32: &str,
gov_action_tx_hash_hex: &str,
gov_action_index: u32,
vote: VoteChoice,
anchor_url: Option<&str>,
anchor_data_hash_hex: Option<&str>,
params: &ProtocolParams,
) -> Result<Vec<u8>, WalletError> {
use pallas_codec::utils::{NonEmptyKeyValuePairs, Nullable};
use pallas_primitives::conway::{
Anchor, GovActionId, Voter, VotingProcedure, VotingProcedures,
};
let stake_pkh = stake_key.public_key_hash();
let voter = Voter::DRepKey(stake_pkh);
let gov_tx_hash = parse_tx_hash(gov_action_tx_hash_hex)?;
let gov_action_id = GovActionId {
transaction_id: gov_tx_hash,
action_index: gov_action_index,
};
let anchor: Nullable<Anchor> = match (anchor_url, anchor_data_hash_hex) {
(Some(url), Some(hash_hex)) => {
if hash_hex.len() != 64 {
return Err(WalletError::Derivation(format!(
"anchor_data_hash must be 64-char hex, got {}",
hash_hex.len()
)));
}
let mut h_arr = [0u8; 32];
for i in 0..32 {
h_arr[i] = u8::from_str_radix(&hash_hex[i * 2..i * 2 + 2], 16).map_err(|_| {
WalletError::Derivation("invalid hex in anchor_data_hash".into())
})?;
}
Nullable::Some(Anchor {
url: url.to_string(),
content_hash: Hash::<32>::new(h_arr),
})
}
(None, None) => Nullable::Null,
_ => {
return Err(WalletError::Derivation(
"anchor_url and anchor_data_hash must both be set or both omitted".into(),
));
}
};
let procedure = VotingProcedure {
vote: vote.into_pallas(),
anchor,
};
let inner = NonEmptyKeyValuePairs::Def(vec![(gov_action_id, procedure)]);
let outer: VotingProcedures = NonEmptyKeyValuePairs::Def(vec![(voter, inner)]);
let vp_bytes = minicbor::to_vec(&outer)
.map_err(|e| WalletError::Derivation(format!("encode voting procedures: {e}")))?;
sign_voting_tx(
payment_key,
stake_key,
network,
available_utxos,
change_address_bech32,
vp_bytes,
params,
)
}
/// Shared voting-tx signing: builds a tx with `voting_procedures`
/// attached, two-pass-fee, dual-witness (payment + stake) signed.
#[allow(clippy::too_many_arguments)]
fn sign_voting_tx(
payment_key: &PaymentKey,
stake_key: &StakeKey,
network: Network,
available_utxos: &[InputUtxo],
change_address_bech32: &str,
voting_procedures_cbor: Vec<u8>,
params: &ProtocolParams,
) -> Result<Vec<u8>, WalletError> {
let change_addr = parse_address(change_address_bech32)?;
let network_id = network_id_for(network);
let fee_pass1: u64 = 500_000;
let need = fee_pass1
.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: need {need} (fee+min_change), have {acc}"
)));
}
let total_in: u64 = selected.iter().map(|u| u.lovelace).sum();
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 change_out = Output::new(change_addr.clone(), change_lovelace);
staging = staging.output(change_out);
staging = staging.voting_procedures(voting_procedures_cbor.clone());
staging = staging.fee(fee).network_id(network_id);
Ok(staging)
};
let change_pass1 = total_in
.checked_sub(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;
let est_signed = (unsigned.len() as u64) + TWO_WITNESS_OVERHEAD_BYTES;
let real_fee = params.min_fee_for_size(est_signed);
let final_change = total_in
.checked_sub(real_fee)
.ok_or_else(|| WalletError::Derivation(format!(
"insufficient funds for fee: total_in={total_in} fee={real_fee}"
)))?;
if final_change < params.min_utxo_lovelace {
return Err(WalletError::Derivation(format!(
"change ({final_change}) below min utxo ({})",
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}")))?;
let payment_signed = add_witness(payment_key, &built.tx_bytes.0)?;
let stake_payment_proxy = crate::stake::stake_key_as_payment_proxy(stake_key);
let fully_signed = add_witness(&stake_payment_proxy, &payment_signed)?;
Ok(fully_signed)
}
/// Build + sign a DRep deregistration tx. Returns the 500 ADA deposit
/// to the wallet.
#[allow(clippy::too_many_arguments)]

View file

@ -65,8 +65,8 @@ pub use plutus::{
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_vote_delegation, parse_drep_target, DRepTarget,
DREP_REGISTRATION_DEPOSIT_LOVELACE,
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,