From 68e493dd2f2bbc85983eb0886e6c8ca6ddd3199c Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 6 May 2026 06:41:52 -0700 Subject: [PATCH] refactor(dao): wire KoiosDaoReader::list_proposals + use it from vote tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first attempt's vote MCP tool inlined a Koios address_info pull helper in tools.rs that needed reqwest + pallas_codec + pallas_primitives as direct deps on aldabra-mcp — which it doesn't have. Compile failed. Cleaner: move the work into the dao crate where those deps already live. - ProposalUtxo gains `lovelace` + `proposal_st_asset_name_hex`. The vote builder needs both to construct the new proposal output. - KoiosDaoReader::list_proposals (was stubbed) now reads cfg.proposal_addr, decodes every UTxO's inline datum to ProposalDatum, and matches the ProposalST asset name against cfg.proposal_st_policy when set, falling back to the first asset on the utxo when not (Sulkta convention is one ProposalST + nothing else). - KoiosAsset.asset_name no longer #[allow(dead_code)] — it's read now. - tools.rs::dao_proposal_vote_unsigned switches to dao_reader.list_proposals + drops the inline pull helper. ~150 LOC simpler. --- crates/aldabra-dao/src/reader.rs | 88 +++++++++++++++--- crates/aldabra-mcp/src/tools.rs | 154 ++++--------------------------- 2 files changed, 90 insertions(+), 152 deletions(-) diff --git a/crates/aldabra-dao/src/reader.rs b/crates/aldabra-dao/src/reader.rs index 734d984..815d94d 100644 --- a/crates/aldabra-dao/src/reader.rs +++ b/crates/aldabra-dao/src/reader.rs @@ -44,11 +44,15 @@ pub struct StakeUtxo { pub gov_token_quantity: u64, } -/// One on-chain proposal at the proposal script address (derived from -/// the gov-token policy). +/// One on-chain proposal at the proposal script address. #[derive(Debug, Clone)] pub struct ProposalUtxo { pub utxo_ref: String, + /// Lovelace at this UTxO. Preserved in vote/cosign/advance outputs. + pub lovelace: u64, + /// Asset name (hex) of the ProposalST token on this UTxO. Sulkta + /// convention is empty bytes; community DAOs may use something else. + pub proposal_st_asset_name_hex: String, pub datum: ProposalDatum, } @@ -195,18 +199,72 @@ impl DaoReader for KoiosDaoReader { Ok(out) } - async fn list_proposals(&self, _cfg: &DaoConfig) -> DaoResult> { - // Proposals live at the proposal script address, which is derived - // from the Agora deployment + gov-token-policy parameters. We - // don't compute that derivation in Phase 1 (it lands in Phase 4 - // alongside reference_scripts.rs). For now: return empty + a - // tracked-todo string. Real wiring: decode proposal script - // hash → bech32 → call address_info → filter to inline-datum - // UTxOs → decode ProposalDatum. - Err(DaoError::State( - "list_proposals pending Phase 4 (proposal script address discovery)" - .into(), - )) + async fn list_proposals(&self, cfg: &DaoConfig) -> DaoResult> { + let proposal_addr = cfg.proposal_addr.as_deref().ok_or_else(|| { + DaoError::Config( + "DaoConfig.proposal_addr missing — register the DAO with proposal_addr \ + or run dao_discover_scripts first" + .into(), + ) + })?; + let infos = self.address_info(proposal_addr).await?; + let utxos = infos + .into_iter() + .next() + .map(|i| i.utxo_set) + .unwrap_or_default(); + + let mut out = Vec::new(); + for u in utxos { + // Need an inline datum to be a real proposal UTxO. Skip orphans. + let Some(ref d) = u.inline_datum else { continue }; + let pd = match decode_datum_cbor_hex(&d.bytes) { + Ok(pd) => pd, + Err(_) => continue, + }; + let datum = match ProposalDatum::from_plutus_data(&pd) { + Ok(d) => d, + Err(_) => continue, + }; + + // Pick the ProposalST asset name. We don't have the policy id + // baked into the trait surface (cfg.proposal_st_policy may or + // may not be populated yet), so: + // - if cfg.proposal_st_policy IS set, match exactly on it; + // - otherwise fall back to "the first asset on the utxo," + // which is right for Sulkta convention (1 ProposalST + 0 + // other assets) but defends against the case where a + // community DAO bundles other tokens in the proposal output. + let proposal_st_asset_name_hex = match cfg.proposal_st_policy.as_deref() { + Some(target_policy) => u + .asset_list + .as_ref() + .into_iter() + .flatten() + .find_map(|a| { + if a.policy_id == target_policy { + Some(a.asset_name.clone().unwrap_or_default()) + } else { + None + } + }) + .unwrap_or_default(), + None => u + .asset_list + .as_ref() + .and_then(|al| al.first()) + .and_then(|a| a.asset_name.clone()) + .unwrap_or_default(), + }; + + out.push(ProposalUtxo { + utxo_ref: format!("{}#{}", u.tx_hash, u.tx_index), + lovelace: u.value.parse().unwrap_or(0), + proposal_st_asset_name_hex, + datum, + }); + } + Ok(out) } } @@ -235,7 +293,7 @@ struct KoiosUtxo { #[derive(Debug, Deserialize)] struct KoiosAsset { policy_id: String, - #[allow(dead_code)] + #[serde(default)] asset_name: Option, quantity: String, } diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index aa0633a..0feca93 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -1832,34 +1832,25 @@ impl WalletService { "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(|| { + // Find this proposal among the UTxOs at proposal_addr. Goes + // through the DaoReader which decodes inline datums + filters + // out orphan/non-proposal UTxOs. + let proposals = self + .inner + .dao_reader + .list_proposals(&cfg) + .await + .map_err(|e| e.to_string())?; + let target = proposals + .into_iter() + .find(|p| p.datum.proposal_id == proposal_id) + .ok_or_else(|| { format!("no proposal with proposal_id={} found at {}", proposal_id, proposal_addr) })?; + let (prop_tx, prop_idx) = parse_utxo_ref(&target.utxo_ref)?; + let prop_lovelace = target.lovelace; + let prop_st_name_hex = target.proposal_st_asset_name_hex; + let prop_datum = target.datum; // Find the voter's stake. let voter_pkh = self.wallet_pkh()?; @@ -2215,117 +2206,6 @@ 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. ///