feat(dao-mcp): wire dao_proposal_create_unsigned + drop unused imports

This commit is contained in:
Kayos 2026-05-05 19:50:17 -07:00
parent 3ac10f7f4b
commit 93edf0c9c3
3 changed files with 159 additions and 3 deletions

View file

@ -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};

View file

@ -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::{

View file

@ -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<String, String> {
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<concatenated_key, qty>`
// 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<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DaoProposalCreateArgs {
/// Named DAO. Falls through to active if omitted.
#[serde(default)]
pub dao: Option<String>,
/// 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