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:
parent
77d4b2d0b5
commit
eb48a5cf3e
4 changed files with 279 additions and 10 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue