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:
Kayos 2026-05-06 07:14:17 -07:00
parent 4d3ef03978
commit 6443dcd858
4 changed files with 279 additions and 10 deletions

14
Cargo.lock generated
View file

@ -1253,7 +1253,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "pallas-addresses"
version = "0.32.1"
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72"
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#507fd9da15f1239ff2df866e0d7601d4518e83a3"
dependencies = [
"base58",
"bech32",
@ -1268,7 +1268,7 @@ dependencies = [
[[package]]
name = "pallas-codec"
version = "0.32.1"
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72"
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#507fd9da15f1239ff2df866e0d7601d4518e83a3"
dependencies = [
"hex",
"minicbor",
@ -1279,7 +1279,7 @@ dependencies = [
[[package]]
name = "pallas-crypto"
version = "0.32.1"
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72"
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#507fd9da15f1239ff2df866e0d7601d4518e83a3"
dependencies = [
"cryptoxide",
"hex",
@ -1293,7 +1293,7 @@ dependencies = [
[[package]]
name = "pallas-primitives"
version = "0.32.1"
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72"
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#507fd9da15f1239ff2df866e0d7601d4518e83a3"
dependencies = [
"base58",
"bech32",
@ -1308,7 +1308,7 @@ dependencies = [
[[package]]
name = "pallas-traverse"
version = "0.32.1"
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72"
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#507fd9da15f1239ff2df866e0d7601d4518e83a3"
dependencies = [
"hex",
"itertools",
@ -1324,7 +1324,7 @@ dependencies = [
[[package]]
name = "pallas-txbuilder"
version = "0.32.1"
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72"
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#507fd9da15f1239ff2df866e0d7601d4518e83a3"
dependencies = [
"hex",
"pallas-addresses",
@ -1341,7 +1341,7 @@ dependencies = [
[[package]]
name = "pallas-wallet"
version = "0.32.1"
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#68221fbcb57ea499502ad23547313ff91a21ba72"
source = "git+http://kayos:***REDACTED***@192.168.0.5:3001/Sulkta-Coop/pallas.git?branch=feat-aux-data#507fd9da15f1239ff2df866e0d7601d4518e83a3"
dependencies = [
"bech32",
"bip39",

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,

View file

@ -387,6 +387,23 @@ pub struct DrepDeregisterArgs {
// No args — uses the wallet's stake credential.
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DrepVoteCastArgs {
/// Conway governance action's tx hash (64-char hex).
pub gov_action_tx_hash: String,
/// The action_index inside that tx (typically 0).
pub gov_action_index: u32,
/// One of "yes", "no", "abstain". Case-insensitive.
pub vote: String,
/// Optional CIP-100 anchor URL (off-chain rationale for the vote).
#[serde(default)]
pub anchor_url: Option<String>,
/// 64-char hex blake2b-256 of the anchor content. Required when
/// anchor_url is set.
#[serde(default)]
pub anchor_data_hash_hex: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct TxSummaryArgs {
/// Hex-encoded Conway-era tx CBOR — unsigned, partially-signed,
@ -1235,6 +1252,67 @@ impl WalletService {
Ok(tx_hash)
}
#[tool(
name = "wallet_drep_vote_cast",
description = "Conway: cast this wallet's DRep vote on a governance action. Args: gov_action_tx_hash (hex), gov_action_index (u32, typically 0), vote ('yes' | 'no' | 'abstain'), anchor_url (optional CIP-100 rationale URL), anchor_data_hash_hex (optional 64-char blake2b-256 hash; required if anchor_url set). The wallet's stake credential must already be registered as a DRep for the vote to count on chain. Returns submitted tx hash."
)]
async fn wallet_drep_vote_cast(
&self,
#[tool(aggr)] DrepVoteCastArgs {
gov_action_tx_hash,
gov_action_index,
vote,
anchor_url,
anchor_data_hash_hex,
}: DrepVoteCastArgs,
) -> Result<String, String> {
let vote_choice = match vote.to_ascii_lowercase().as_str() {
"yes" => aldabra_core::VoteChoice::Yes,
"no" => aldabra_core::VoteChoice::No,
"abstain" => aldabra_core::VoteChoice::Abstain,
other => return Err(format!("vote must be yes/no/abstain, got {other:?}")),
};
let utxos = self
.inner
.chain
.get_utxos(&self.inner.address)
.await
.map_err(|e| format!("fetch utxos: {e}"))?;
if utxos.is_empty() {
return Err(format!("no utxos at wallet address {}", self.inner.address));
}
let inputs: Vec<InputUtxo> = utxos
.into_iter()
.map(|u| InputUtxo {
tx_hash_hex: u.tx_hash,
output_index: u.output_index,
lovelace: u.lovelace,
assets: u.assets,
})
.collect();
let cbor = aldabra_core::governance::build_signed_drep_vote_cast(
&self.inner.payment_key,
&self.inner.stake_key,
self.inner.network,
&inputs,
&self.inner.address,
&gov_action_tx_hash,
gov_action_index,
vote_choice,
anchor_url.as_deref(),
anchor_data_hash_hex.as_deref(),
&ProtocolParams::default(),
)
.map_err(|e| format!("build/sign vote cast: {e}"))?;
let tx_hash = self
.inner
.chain
.submit_tx(&cbor)
.await
.map_err(|e| format!("submit: {e}"))?;
Ok(tx_hash)
}
#[tool(
name = "wallet_mint_unsigned",
description = "Build a mint TX without signing — for cold-sign or multi-sig flows. Args: dest_address, dest_lovelace, asset_name_hex, quantity, policy (optional, defaults to wallet single-sig; pass {type:'nofk',n:2,signer_pkhs_hex:[..]} for multi-sig treasury), metadata (optional CIP-25), disclosed_signer_pkh_hex (optional, defaults to wallet's pkh). Returns JSON {cbor_hex, summary}. Pass through wallet_sign_partial chain, then wallet_submit_signed_tx."
@ -2990,7 +3068,7 @@ impl ServerHandler for WalletService {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
instructions: Some(
"aldabra — Cardano lite wallet + DAO client over MCP. wallet_*: read (address/balance/utxos/network/stake_address), send (wallet_send with optional inline datum for script locks, wallet_send_unsigned + wallet_sign_partial + wallet_submit_signed_tx for cold/multi-sig, wallet_tx_status), mint (wallet_policy_create, wallet_mint with CIP-25 metadata, wallet_mint_cip68_nft for ref+user NFT pairs, wallet_mint_unsigned), Plutus (wallet_script_spend), stake (wallet_stake_delegate), Conway governance (wallet_vote_delegate to a DRep, wallet_drep_register / wallet_drep_deregister for becoming a DRep). chain_*: read-only Koios passthroughs (chain_tx_info, chain_address_info, chain_pool_list, chain_pool_info, chain_epoch_params, chain_asset_info, chain_account_info, chain_tip). dao_*: native Agora-on-Cardano DAO client. Multi-DAO via $ALDABRA_DATA/daos/<name>.json — register multiple DAOs (Sulkta, Bob's, Alice's), switch active with dao_use. Management: dao_register, dao_list, dao_use, dao_remove, dao_show. Live reads: dao_governor_state (thresholds + timing + nextProposalId), dao_stake_list (all stakes, filtered to the DAO's gov token), dao_my_stake (just this wallet's stake by pkh match). Write paths (unsigned-first; caller signs+submits): dao_proposal_create_unsigned (mint a new proposal), dao_proposal_cosign_unsigned (add wallet's stake as a Draft cosigner — multi-stake bridge), dao_proposal_vote_unsigned (vote on a VotingReady proposal), dao_proposal_advance_unsigned (state-machine push: Draft→VotingReady→Locked→Finished), dao_stake_destroy_unsigned (burn StakeST + return TRP). Each write tool pre-flights every Plutarch validator check client-side so failed txs don't burn fees.".into(),
"aldabra — Cardano lite wallet + DAO client over MCP. wallet_*: read (address/balance/utxos/network/stake_address), send (wallet_send with optional inline datum for script locks, wallet_send_unsigned + wallet_sign_partial + wallet_submit_signed_tx for cold/multi-sig, wallet_tx_status), mint (wallet_policy_create, wallet_mint with CIP-25 metadata, wallet_mint_cip68_nft for ref+user NFT pairs, wallet_mint_unsigned), Plutus (wallet_script_spend), stake (wallet_stake_delegate), Conway governance (wallet_vote_delegate to a DRep, wallet_drep_register / wallet_drep_deregister for becoming a DRep, wallet_drep_vote_cast for casting Yes/No/Abstain votes on governance actions). chain_*: read-only Koios passthroughs (chain_tx_info, chain_address_info, chain_pool_list, chain_pool_info, chain_epoch_params, chain_asset_info, chain_account_info, chain_tip). dao_*: native Agora-on-Cardano DAO client. Multi-DAO via $ALDABRA_DATA/daos/<name>.json — register multiple DAOs (Sulkta, Bob's, Alice's), switch active with dao_use. Management: dao_register, dao_list, dao_use, dao_remove, dao_show. Live reads: dao_governor_state (thresholds + timing + nextProposalId), dao_stake_list (all stakes, filtered to the DAO's gov token), dao_my_stake (just this wallet's stake by pkh match). Write paths (unsigned-first; caller signs+submits): dao_proposal_create_unsigned (mint a new proposal), dao_proposal_cosign_unsigned (add wallet's stake as a Draft cosigner — multi-stake bridge), dao_proposal_vote_unsigned (vote on a VotingReady proposal), dao_proposal_advance_unsigned (state-machine push: Draft→VotingReady→Locked→Finished), dao_stake_destroy_unsigned (burn StakeST + return TRP). Each write tool pre-flights every Plutarch validator check client-side so failed txs don't burn fees.".into(),
),
..Default::default()
}