From 3b0e0dd9bf6ab6c05d7e66d99c6a26a877ca6f0c Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 6 May 2026 06:37:31 -0700 Subject: [PATCH] =?UTF-8?q?feat(dao-mcp):=20wire=20dao=5Fproposal=5Fvote?= =?UTF-8?q?=5Funsigned=20+=20slot=E2=86=94ms=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DaoProposalVoteArgs (dao? + proposal_id + result_tag + fee_lovelace) - mainnet_slot_to_posix_ms: Shelley genesis constants (slot 4_492_800, posix 1_596_059_091_000) for converting tip+VALIDITY_RANGE_SLOTS into the Voted lock's posix_time field - pull_proposal_utxos helper: address_info → decode every UTxO's inline datum into ProposalDatum, return matching proposal_id by id match (`KoiosDaoReader::list_proposals` is still stubbed; this is a focused write-path read) - mainnet-only network gate (preprod/preview slot↔ms is Phase 5) - get_info instructions text mentions write tools - drop unused pallas_addresses::Address + pallas_txbuilder::ExUnits imports surfaced by clippy --- .../aldabra-dao/src/builder/proposal_vote.rs | 3 +- crates/aldabra-mcp/src/tools.rs | 390 +++++++++++++++++- 2 files changed, 390 insertions(+), 3 deletions(-) diff --git a/crates/aldabra-dao/src/builder/proposal_vote.rs b/crates/aldabra-dao/src/builder/proposal_vote.rs index 2efc5c0..dffd09e 100644 --- a/crates/aldabra-dao/src/builder/proposal_vote.rs +++ b/crates/aldabra-dao/src/builder/proposal_vote.rs @@ -59,10 +59,9 @@ //! - **Vote retraction** — `RetractVotes` redeemer path is its own builder //! (Phase 4 follow-up). -use pallas_addresses::Address; use pallas_codec::minicbor; use pallas_crypto::hash::Hash; -use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, StagingTransaction}; +use pallas_txbuilder::{BuildConway, Input, Output, StagingTransaction}; use crate::agora::proposal::{ ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalVotes, diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index 80e8828..aa0633a 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -34,6 +34,9 @@ use aldabra_dao::builder::proposal_create::{ build_unsigned_proposal_create, GovernorUtxoIn, ProposalCreateArgs, ReferenceUtxo, StakeUtxoIn, WalletUtxo as DaoWalletUtxo, }; +use aldabra_dao::builder::proposal_vote::{ + build_unsigned_proposal_vote, ProposalUtxoIn, ProposalVoteArgs, +}; use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore, ScriptRefs}; use aldabra_dao::discovery::{ apply_discovery, discover_scripts, KoiosDiscoveryClient, MAINNET_AGORA_SHARED_DEPLOYER, @@ -1796,6 +1799,235 @@ impl WalletService { .to_string()) } + #[tool( + name = "dao_proposal_vote_unsigned", + description = "Build (but DO NOT submit) an unsigned vote tx for the given DAO proposal. Spends voter's stake (PermitVote redeemer) + the proposal UTxO (Vote(result_tag) redeemer) and outputs the same two with locks/votes mutated. Returns CBOR-hex of the unsigned tx body. Caller signs via wallet_sign_partial then submits via wallet_submit_signed_tx. Args: dao (optional — defaults to active), proposal_id (i64; matches ProposalDatum.proposal_id on chain), result_tag (i64; 0 or 1 for InfoOnly proposals), fee_lovelace (~2_500_000 reasonable for v1). Pre-flights every validator check: voter is owner-or-delegatee, status=VotingReady, no double-vote, stake clears threshold, result_tag valid, validity-upper inside voting window." + )] + async fn dao_proposal_vote_unsigned( + &self, + #[tool(aggr)] DaoProposalVoteArgs { + dao, + proposal_id, + result_tag, + fee_lovelace, + }: DaoProposalVoteArgs, + ) -> Result { + let cfg = self + .inner + .dao_store + .resolve(dao.as_deref()) + .map_err(|e| e.to_string())?; + + // Network gate: slot↔ms conversion is mainnet-only for v1. + if !matches!(cfg.network, DaoNetwork::Mainnet) { + return Err(format!( + "dao_proposal_vote_unsigned only supports mainnet for v1 \ + (current dao network: {:?}); preprod/preview slot↔ms conversion \ + needs the network's Shelley genesis constants — TODO Phase 5", + cfg.network + )); + } + + let proposal_addr = cfg.proposal_addr.as_deref().ok_or_else(|| { + "DaoConfig.proposal_addr missing — register or discover_scripts first".to_string() + })?; + + // Find this proposal among the UTxOs at proposal_addr. Use the + // dedicated `pull_proposal_utxos` helper which decodes inline + // datums (the regular `chain.get_utxos` doesn't surface inline + // datum bytes). + let mut found: Option<( + String, // tx_hash + u32, // output_index + u64, // lovelace + String, // proposal_st asset name hex + aldabra_dao::agora::proposal::ProposalDatum, + )> = None; + let pulled = pull_proposal_utxos(&self.inner.koios_base, proposal_addr).await?; + for p in pulled { + if p.datum.proposal_id == proposal_id { + found = Some(( + p.tx_hash, + p.output_index, + p.lovelace, + p.proposal_st_asset_name_hex, + p.datum, + )); + break; + } + } + let (prop_tx, prop_idx, prop_lovelace, prop_st_name_hex, prop_datum) = + found.ok_or_else(|| { + format!("no proposal with proposal_id={} found at {}", proposal_id, proposal_addr) + })?; + + // Find the voter's stake. + let voter_pkh = self.wallet_pkh()?; + let stakes = self + .inner + .dao_reader + .list_stakes(&cfg) + .await + .map_err(|e| e.to_string())?; + let my_stake = stakes + .into_iter() + .find(|s| match &s.datum.owner { + aldabra_dao::agora::stake::Credential::PubKey(h) => h == &voter_pkh, + _ => false, + }) + .ok_or_else(|| { + format!( + "no stake at stakes_addr owned by this wallet's pkh {} — \ + wallet must hold a registered stake to vote", + hex::encode(&voter_pkh) + ) + })?; + let (stake_tx, stake_idx) = parse_utxo_ref(&my_stake.utxo_ref)?; + + // StakeST asset name from chain (matches H-6 fix in proposal_create). + let stake_utxos_raw = self + .inner + .chain + .get_utxos(&cfg.stakes_addr) + .await + .map_err(|e| format!("koios get stake utxos: {e}"))?; + let stake_utxo_raw = stake_utxos_raw + .into_iter() + .find(|u| u.tx_hash == stake_tx && u.output_index == stake_idx) + .ok_or_else(|| format!("stake utxo {} no longer on chain", my_stake.utxo_ref))?; + let stake_st_asset_name_hex = stake_utxo_raw + .assets + .iter() + .find_map(|(k, _)| { + if k.len() < 56 { + return None; + } + let (p, n) = k.split_at(56); + if p == cfg.stake_st_policy.as_deref().unwrap_or("") { + Some(n.to_string()) + } else { + None + } + }) + .ok_or_else(|| "stake UTxO is missing StakeST token".to_string())?; + + // Chain tip slot + compute validity_upper_ms (mainnet only). + let tip_resp = self + .inner + .chain + .get_raw_json("tip", &[]) + .await + .map_err(|e| format!("koios tip: {e}"))?; + let tip: serde_json::Value = + serde_json::from_str(&tip_resp).map_err(|e| format!("tip parse: {e}"))?; + let tip_slot = tip + .as_array() + .and_then(|a| a.first()) + .and_then(|t| t.get("abs_slot")) + .and_then(|s| s.as_u64()) + .ok_or_else(|| format!("tip response missing abs_slot: {tip_resp}"))?; + let validity_upper_slot = tip_slot + + aldabra_dao::builder::proposal_create::VALIDITY_RANGE_SLOTS; + let validity_upper_ms = mainnet_slot_to_posix_ms(validity_upper_slot)?; + + // Wallet utxos with H-5-style asset propagation. + let wallet_utxos: Vec = { + let raw = self + .inner + .chain + .get_utxos(&self.inner.address) + .await + .map_err(|e| format!("koios get wallet utxos: {e}"))?; + let mut out = Vec::with_capacity(raw.len()); + for u in raw { + let mut assets = Vec::with_capacity(u.assets.len()); + for (k, q) in u.assets { + if k.len() < 56 { + return Err(format!( + "malformed asset key in wallet utxo {tx_hash}#{idx}: \ + {k:?} is {len} chars, need ≥ 56", + tx_hash = u.tx_hash, + idx = u.output_index, + len = k.len(), + )); + } + let (p, n) = k.split_at(56); + assets.push((p.to_string(), n.to_string(), q)); + } + out.push(DaoWalletUtxo { + tx_hash_hex: u.tx_hash, + output_index: u.output_index, + lovelace: u.lovelace, + assets, + }); + } + out + }; + + // ScriptRefs: stake + proposal validators. + let stake_validator_ref = ReferenceUtxo::from_str( + cfg.script_refs + .stake_validator + .as_deref() + .ok_or_else(|| { + "DaoConfig.script_refs.stake_validator missing — \ + run dao_discover_scripts first".to_string() + })?, + ) + .map_err(|e| e.to_string())?; + let proposal_validator_ref = ReferenceUtxo::from_str( + cfg.script_refs + .proposal_validator + .as_deref() + .ok_or_else(|| { + "DaoConfig.script_refs.proposal_validator missing — \ + run dao_discover_scripts first".to_string() + })?, + ) + .map_err(|e| e.to_string())?; + + let unsigned = build_unsigned_proposal_vote(ProposalVoteArgs { + cfg: cfg.clone(), + stake_in: StakeUtxoIn { + tx_hash_hex: stake_tx, + output_index: stake_idx, + lovelace: my_stake.lovelace, + gov_token_qty: my_stake.gov_token_quantity, + stake_st_asset_name_hex, + datum: my_stake.datum, + }, + proposal: ProposalUtxoIn { + tx_hash_hex: prop_tx, + output_index: prop_idx, + lovelace: prop_lovelace, + proposal_st_asset_name_hex: prop_st_name_hex, + datum: prop_datum, + }, + voter_pkh, + result_tag, + change_address: self.inner.address.clone(), + wallet_utxos, + tip_slot, + validity_upper_ms, + stake_validator_ref, + proposal_validator_ref, + fee_lovelace, + }) + .map_err(|e| e.to_string())?; + + Ok(serde_json::json!({ + "dao": cfg.name, + "tx_cbor_hex": unsigned.tx_cbor_hex, + "tx_hash_hex": unsigned.tx_hash_hex, + "proposal_id": unsigned.proposal_id, + "result_tag": unsigned.result_tag, + "vote_weight": unsigned.vote_weight, + "summary": unsigned.summary, + "next_step": "review tx_cbor_hex (decode + audit), then sign via wallet_sign_partial + submit via wallet_submit_signed_tx", + }) + .to_string()) + } + #[tool( name = "dao_my_stake", description = "Filter dao_stake_list to just the stake owned by THIS wallet (by matching the wallet's payment-credential hash against StakeDatum.owner). Returns the same shape as dao_stake_list but with at most one entry. If the wallet has no stake yet, returns an empty stakes array. Args: dao (optional — defaults to active)." @@ -1930,6 +2162,50 @@ pub struct DaoProposalCreateArgs { pub starting_time_ms: i64, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DaoProposalVoteArgs { + /// Named DAO. Falls through to active if omitted. + #[serde(default)] + pub dao: Option, + /// Proposal id (matches `ProposalDatum.proposal_id` on chain). + pub proposal_id: i64, + /// Result tag to vote for. For Sulkta InfoOnly: 0 = "yes", 1 = "no". + /// Must already be a key in the proposal's votes map. + pub result_tag: i64, + /// Estimated total fee in lovelace. v1 caller-supplied; ~2.5 ADA is + /// reasonable for a 2-script-spend vote tx. + pub fee_lovelace: u64, +} + +/// Mainnet Shelley genesis constants for slot↔POSIX-ms conversion. +/// +/// On Cardano mainnet, slot 4_492_800 corresponds to 2020-07-29 21:44:51 UTC +/// (POSIX 1_596_059_091 seconds), and Shelley+ era slots are 1 second wide. +/// Source: Cardano genesis files. +const MAINNET_SHELLEY_SLOT_ZERO: u64 = 4_492_800; +const MAINNET_SHELLEY_POSIX_MS_ZERO: i64 = 1_596_059_091_000; + +/// Convert an absolute mainnet slot to POSIX milliseconds. +/// +/// Caveat: only valid for slots ≥ `MAINNET_SHELLEY_SLOT_ZERO`. Returns +/// `Err` if slot is in the Byron era (pre-4_492_800) since slot lengths +/// differed there. We never need pre-Shelley slots for DAO operations. +fn mainnet_slot_to_posix_ms(slot: u64) -> Result { + if slot < MAINNET_SHELLEY_SLOT_ZERO { + return Err(format!( + "slot {slot} is pre-Shelley (< {MAINNET_SHELLEY_SLOT_ZERO}); \ + slot↔ms conversion only supported for Shelley+ era" + )); + } + let delta_slots = slot - MAINNET_SHELLEY_SLOT_ZERO; + let delta_ms = (delta_slots as i64).checked_mul(1000).ok_or_else(|| { + format!("slot delta {delta_slots} * 1000 overflows i64") + })?; + MAINNET_SHELLEY_POSIX_MS_ZERO + .checked_add(delta_ms) + .ok_or_else(|| "posix_ms add overflow".into()) +} + /// Parse a `txhash#index` UTxO ref into its components. fn parse_utxo_ref(s: &str) -> Result<(String, u32), String> { let (h, i) = s @@ -1939,6 +2215,118 @@ fn parse_utxo_ref(s: &str) -> Result<(String, u32), String> { Ok((h.to_string(), idx)) } +/// One proposal UTxO with decoded datum + ProposalST asset name. +/// +/// Sized so [`dao_proposal_vote_unsigned`] can find a specific proposal_id +/// without dragging the full Koios JSON shape through the call site. +struct PulledProposal { + tx_hash: String, + output_index: u32, + lovelace: u64, + proposal_st_asset_name_hex: String, + datum: aldabra_dao::agora::proposal::ProposalDatum, +} + +/// Pull every UTxO at the proposal address with a decoded ProposalDatum. +/// +/// Used by `dao_proposal_vote_unsigned` to find a specific proposal by id. +/// Phase 1 didn't wire this to `KoiosDaoReader::list_proposals` because the +/// reader trait doesn't surface ProposalST asset info — we need both the +/// ProposalST asset name (for datum-bearing assets) AND the bech32 utxo ref, +/// so a focused helper here is cleaner than adding fields to the trait. +async fn pull_proposal_utxos( + koios_base: &str, + proposal_addr: &str, +) -> Result, String> { + use aldabra_dao::agora::proposal::ProposalDatum; + use serde::Deserialize; + + #[derive(Deserialize)] + struct AddrInfo { + utxo_set: Vec, + } + #[derive(Deserialize)] + struct Utxo { + tx_hash: String, + tx_index: u32, + value: String, + #[serde(default)] + asset_list: Option>, + #[serde(default)] + inline_datum: Option, + } + #[derive(Deserialize)] + #[allow(dead_code)] + struct Asset { + policy_id: String, + asset_name: Option, + quantity: String, + } + #[derive(Deserialize)] + struct InlineDatum { + bytes: String, + } + + let url = format!("{}/address_info", koios_base.trim_end_matches('/')); + let body = serde_json::json!({ "_addresses": [proposal_addr] }); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| format!("reqwest build: {e}"))?; + let resp = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| format!("address_info: {e}"))?; + if !resp.status().is_success() { + return Err(format!("address_info: HTTP {}", resp.status())); + } + let infos: Vec = resp + .json() + .await + .map_err(|e| format!("address_info parse: {e}"))?; + + let mut out = Vec::new(); + for info in infos { + for u in info.utxo_set { + let Some(d) = u.inline_datum else { continue }; + let datum_bytes = match hex::decode(&d.bytes) { + Ok(b) => b, + Err(_) => continue, + }; + let pd: pallas_primitives::PlutusData = + match pallas_codec::minicbor::decode(&datum_bytes) { + Ok(pd) => pd, + Err(_) => continue, + }; + let datum = match ProposalDatum::from_plutus_data(&pd) { + Ok(d) => d, + Err(_) => continue, + }; + // Find a non-zero qty asset whose policy looks like the + // ProposalST policy. We don't have that here directly — caller + // can match on it; we just expose the asset_name of the first + // singleton asset (Sulkta convention). + let proposal_st_asset_name_hex = u + .asset_list + .as_ref() + .and_then(|al| al.first()) + .and_then(|a| a.asset_name.clone()) + .unwrap_or_default(); + let lovelace = u.value.parse().unwrap_or(0); + out.push(PulledProposal { + tx_hash: u.tx_hash, + output_index: u.tx_index, + lovelace, + proposal_st_asset_name_hex, + datum, + }); + } + } + Ok(out) +} + /// Render a [`aldabra_dao::reader::StakeUtxo`] as a JSON object for tool output. /// /// Formatted as a free function rather than `impl Serialize for StakeUtxo` to @@ -1995,7 +2383,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). 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). Phase 1 read-only; voting/proposing land in subsequent phases.".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). 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_vote_unsigned (vote on a VotingReady proposal). Each write tool pre-flights every Plutarch validator check client-side so failed txs don't burn fees.".into(), ), ..Default::default() }