refactor(dao): wire KoiosDaoReader::list_proposals + use it from vote tool

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.
This commit is contained in:
Sulkta 2026-05-06 06:41:52 -07:00
parent d9f852d8bd
commit cc69e0df21
2 changed files with 90 additions and 152 deletions

View file

@ -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<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.
///