diff --git a/crates/aldabra-dao/src/agora/governor.rs b/crates/aldabra-dao/src/agora/governor.rs index 0f72249..4d07058 100644 --- a/crates/aldabra-dao/src/agora/governor.rs +++ b/crates/aldabra-dao/src/agora/governor.rs @@ -8,7 +8,7 @@ use pallas_primitives::PlutusData; -use crate::agora::plutus_data::{as_int, as_product, constr, int, product}; +use crate::agora::plutus_data::{as_int, as_product, int, product}; use crate::agora::proposal::{ProposalThresholds, ProposalTimingConfig}; use crate::error::{DaoError, DaoResult}; diff --git a/crates/aldabra-dao/src/builder/proposal_create.rs b/crates/aldabra-dao/src/builder/proposal_create.rs index 17d9ec5..d41af55 100644 --- a/crates/aldabra-dao/src/builder/proposal_create.rs +++ b/crates/aldabra-dao/src/builder/proposal_create.rs @@ -40,10 +40,9 @@ use pallas_addresses::Address; use pallas_codec::minicbor; -use pallas_codec::utils::Bytes; use pallas_crypto::hash::Hash; use pallas_primitives::PlutusData; -use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, ScriptKind, StagingTransaction}; +use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, StagingTransaction}; use crate::agora::governor::GovernorDatum; use crate::agora::proposal::{ diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index d798824..26d4efc 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -30,6 +30,10 @@ use std::sync::Arc; use aldabra_chain::{ChainBackend, KoiosClient}; use aldabra_dao::agora::stake::Credential as DaoCredential; +use aldabra_dao::builder::proposal_create::{ + build_unsigned_proposal_create, GovernorUtxoIn, ProposalCreateArgs, ReferenceUtxo, + WalletUtxo as DaoWalletUtxo, +}; use aldabra_dao::config::{DaoConfig, DaoNetwork, DaoStore, ScriptRefs}; use aldabra_dao::reader::{DaoReader, KoiosDaoReader}; use aldabra_core::plutus_cost_models::PLUTUS_V3_COST_MODEL_PREPROD; @@ -1486,6 +1490,135 @@ impl WalletService { Ok(serde_json::json!({ "dao": cfg.name, "stakes": arr }).to_string()) } + #[tool( + name = "dao_proposal_create_unsigned", + description = "Build (but DO NOT submit) an unsigned proposal-creation tx for the given DAO. Returns the CBOR-hex of the unsigned tx body + the new proposal_id. Currently supports InfoOnly proposals only — TreasuryWithdrawal effect path lands in Phase 4c. Caller signs via wallet_sign_partial then submits via wallet_submit_signed_tx. Args: dao (optional — defaults to active), fee_lovelace (suggested ~3_000_000 for v1; refine via koios tx_evaluate), starting_time_ms (POSIX millis to embed in ProposalDatum.starting_time; pass current chain tip's slot * 1000 + epoch start)." + )] + async fn dao_proposal_create_unsigned( + &self, + #[tool(aggr)] DaoProposalCreateArgs { + dao, + fee_lovelace, + starting_time_ms, + }: DaoProposalCreateArgs, + ) -> Result { + let cfg = self + .inner + .dao_store + .resolve(dao.as_deref()) + .map_err(|e| e.to_string())?; + + // Read live governor state so we have the current next_proposal_id + + // datum to copy. + let (governor_utxo_ref, governor_datum) = self + .inner + .dao_reader + .get_governor(&cfg) + .await + .map_err(|e| e.to_string())?; + let (gov_tx_hash, gov_idx) = parse_utxo_ref(&governor_utxo_ref)?; + + // Pull governor lovelace + every wallet UTxO. We need both: + // (a) governor lovelace for tx-balance calculation, + // (b) wallet utxos for funding + collateral selection. + let gov_lovelace = self + .inner + .chain + .get_utxos(&cfg.governor_addr) + .await + .map_err(|e| format!("koios get governor utxos: {e}"))? + .into_iter() + .find(|u| u.tx_hash == gov_tx_hash && u.output_index == gov_idx) + .ok_or_else(|| format!("governor utxo {governor_utxo_ref} no longer present on chain"))? + .lovelace; + + let wallet_utxos = self + .inner + .chain + .get_utxos(&self.inner.address) + .await + .map_err(|e| format!("koios get wallet utxos: {e}"))? + .into_iter() + .map(|u| DaoWalletUtxo { + tx_hash_hex: u.tx_hash, + output_index: u.output_index, + lovelace: u.lovelace, + // Chain backend gives `assets: BTreeMap` + // where the key is `policy_id_hex || asset_name_hex` with the + // policy taking the first 56 chars (28 bytes). Split for + // pallas-txbuilder which wants the parts separate. + assets: u + .assets + .into_iter() + .filter_map(|(k, q)| { + if k.len() >= 56 { + let (p, n) = k.split_at(56); + Some((p.to_string(), n.to_string(), q)) + } else { + None + } + }) + .collect(), + }) + .collect(); + + // ScriptRefs must be populated before this tool can build a tx. + // For Sulkta the values are known from the audit; user must pass + // them in via dao_register or hand-edit the json (until + // dao_discover_scripts ships). + let governor_validator_ref = ReferenceUtxo::from_str( + cfg.script_refs + .governor_validator + .as_deref() + .ok_or_else(|| { + "DaoConfig.script_refs.governor_validator missing — populate before \ + calling dao_proposal_create_unsigned" + .to_string() + })?, + ) + .map_err(|e| e.to_string())?; + let proposal_st_policy_ref = ReferenceUtxo::from_str( + cfg.script_refs + .proposal_st_policy + .as_deref() + .ok_or_else(|| { + "DaoConfig.script_refs.proposal_st_policy missing — populate first" + .to_string() + })?, + ) + .map_err(|e| e.to_string())?; + + let proposer_pkh = self.wallet_pkh()?; + + let unsigned = build_unsigned_proposal_create(ProposalCreateArgs { + cfg: cfg.clone(), + governor: GovernorUtxoIn { + tx_hash_hex: gov_tx_hash, + output_index: gov_idx, + lovelace: gov_lovelace, + datum: governor_datum, + }, + proposer_pkh, + change_address: self.inner.address.clone(), + wallet_utxos, + starting_time_ms, + governor_validator_ref, + proposal_st_policy_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, + "new_proposal_id": unsigned.new_proposal_id, + "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)." @@ -1561,6 +1694,30 @@ pub struct DaoShowArgs { pub dao: Option, } +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct DaoProposalCreateArgs { + /// Named DAO. Falls through to active if omitted. + #[serde(default)] + pub dao: Option, + /// Estimated total fee in lovelace. v1 caller-supplied; future versions + /// will derive from `koios /tx_evaluate`. ~3 ADA is a reasonable bound + /// for an InfoOnly proposal-create on Sulkta-shape thresholds. + pub fee_lovelace: u64, + /// POSIX time in milliseconds to embed in the new ProposalDatum's + /// `starting_time`. Should reflect current chain tip — pass + /// `chain_tip.block_time * 1000` (Koios's `block_time` is in seconds). + pub starting_time_ms: i64, +} + +/// Parse a `txhash#index` UTxO ref into its components. +fn parse_utxo_ref(s: &str) -> Result<(String, u32), String> { + let (h, i) = s + .split_once('#') + .ok_or_else(|| format!("utxo ref {s:?} not in 'txhash#index' form"))?; + let idx: u32 = i.parse().map_err(|e| format!("utxo index {i:?} parse: {e}"))?; + Ok((h.to_string(), idx)) +} + /// 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