From 6443dcd858c1bd7f38aa1363568fbf0e94658f15 Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 6 May 2026 07:14:17 -0700 Subject: [PATCH] feat(governance): wallet_drep_vote_cast + pallas voting_procedures patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>) 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. --- Cargo.lock | 14 +- crates/aldabra-core/src/governance.rs | 191 ++++++++++++++++++++++++++ crates/aldabra-core/src/lib.rs | 4 +- crates/aldabra-mcp/src/tools.rs | 80 ++++++++++- 4 files changed, 279 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c9187cb..8246cae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/aldabra-core/src/governance.rs b/crates/aldabra-core/src/governance.rs index 32dca2f..84db3bb 100644 --- a/crates/aldabra-core/src/governance.rs +++ b/crates/aldabra-core/src/governance.rs @@ -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, 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 = 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, + params: &ProtocolParams, +) -> Result, 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 = available_utxos.to_vec(); + sorted.sort_by_key(|u| std::cmp::Reverse(u.lovelace)); + let mut acc: u64 = 0; + let mut selected: Vec = 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 { + 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)] diff --git a/crates/aldabra-core/src/lib.rs b/crates/aldabra-core/src/lib.rs index b48ddc2..866504a 100644 --- a/crates/aldabra-core/src/lib.rs +++ b/crates/aldabra-core/src/lib.rs @@ -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, diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 104b38a..33b33c3 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -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, + /// 64-char hex blake2b-256 of the anchor content. Required when + /// anchor_url is set. + #[serde(default)] + pub anchor_data_hash_hex: Option, +} + #[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 { + 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 = 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/.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/.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() }