feat(dao-mcp): wire dao_proposal_vote_unsigned + slot↔ms helper

- 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
This commit is contained in:
Kayos 2026-05-06 06:37:31 -07:00
parent a19439f640
commit 3b0e0dd9bf
2 changed files with 390 additions and 3 deletions

View file

@ -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,

View file

@ -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<String, String> {
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 slotms 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<DaoWalletUtxo> = {
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<String>,
/// 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<i64, String> {
if slot < MAINNET_SHELLEY_SLOT_ZERO {
return Err(format!(
"slot {slot} is pre-Shelley (< {MAINNET_SHELLEY_SLOT_ZERO}); \
slotms 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<Vec<PulledProposal>, String> {
use aldabra_dao::agora::proposal::ProposalDatum;
use serde::Deserialize;
#[derive(Deserialize)]
struct AddrInfo {
utxo_set: Vec<Utxo>,
}
#[derive(Deserialize)]
struct Utxo {
tx_hash: String,
tx_index: u32,
value: String,
#[serde(default)]
asset_list: Option<Vec<Asset>>,
#[serde(default)]
inline_datum: Option<InlineDatum>,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct Asset {
policy_id: String,
asset_name: Option<String>,
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<AddrInfo> = 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/<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). 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/<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_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()
}