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:
Kayos 2026-05-06 06:41:52 -07:00
parent 3b0e0dd9bf
commit 68e493dd2f
2 changed files with 90 additions and 152 deletions

View file

@ -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<Vec<ProposalUtxo>> {
// 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<Vec<ProposalUtxo>> {
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<String>,
quantity: String,
}

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