aldabra/crates/aldabra-dao/src/reader.rs
Sulkta 03b5efb3b2 audit: cargo fmt + clippy --fix across workspace + retract_votes cooldown bug fix
Surfaced by Track #38 code audit (2026-05-09):

1. cargo fmt --all: 217 formatting diffs across 35 files. Pure
   whitespace; no semantic changes.

2. cargo clippy --fix: 30 warnings -> 10. Auto-applied:
   - useless format!() (3 sites in builder/proposal_*.rs)
   - needless_borrow_for_generic_args (4 sites)
   - cloned_ref_to_slice_refs (1 site, builder/proposal_cosign.rs)
   - derivable_impls (1 site, dao/config.rs)
   - unused imports/variables (3 sites)

   Remaining 10 warnings are non-trivial (too_many_arguments on a
   constructor at 8 args, FromStr trait shadow, doc_lazy_continuation
   on a few comment blocks). Filed as tech-debt; no action this pass.

3. cargo audit: 0 vulnerabilities. 2 unmaintained advisories on
   transitive deps:
   - paste 1.0.15 (RUSTSEC-2024-0436) via rmcp + pallas-traverse
   - proc-macro-error 1.0.4 (RUSTSEC-2024-0370) via age->i18n-embed-fl
   Both upstream; tracked but no action needed locally.

4. Test failure surfaced: builder::proposal_retract_votes::tests::
   voting_ready_in_window_subtracts_vote_weight failed — cooldown
   check was applied unconditionally for RemoveVoterLockOnly mode,
   blocking the legitimate 'retract during voting window' path
   where the proposal datum mutates (vote weight subtraction). Per
   Agora's premoveLocks rule, cooldown only applies when retracting
   AFTER voting closed but BEFORE Finished — not during the active
   voting window. Fixed by gating cooldown on
   '!proposal_datum_will_change' so the in-window retract path
   bypasses cooldown the same way RemoveAllLocks does.

   Test: 87/87 aldabra-dao lib tests pass post-fix (was 86/87).
2026-05-09 10:27:48 -07:00

334 lines
12 KiB
Rust

//! On-chain DAO state reader.
//!
//! Phase 1 surface — Koios-backed queries that decode Agora datums
//! into the typed Rust structs from [`crate::agora`].
//!
//! ## Why a separate reader vs raw Koios
//!
//! Three reasons:
//!
//! 1. **Datum decoding** — Koios returns datums as hex CBOR strings; we
//! decode them through the agora type ports here so callers get
//! `StakeDatum` / `ProposalDatum` / `GovernorDatum`, not raw hex.
//! 2. **Filtering** — the stakes script address is shared across many
//! Agora DAOs. A naive "list UTxOs at stakes_addr" returns 268+
//! items most of which are not Sulkta's. Stakes for a particular
//! DAO are tagged by the gov-token policy in their value, so we
//! filter here rather than at every call site.
//! 3. **DAO instance routing** — every read takes a [`DaoConfig`] so
//! multi-DAO tools work without baking in a single instance.
//!
//! ## What's NOT here
//!
//! Submission. That's the chain backend's job; we only read.
use pallas_primitives::PlutusData;
use serde::Deserialize;
use crate::agora::{GovernorDatum, ProposalDatum, StakeDatum};
use crate::config::DaoConfig;
use crate::error::{DaoError, DaoResult};
/// One on-chain stake found at the stakes script address, filtered to
/// the gov token policy of [`DaoConfig::gov_token_policy`].
#[derive(Debug, Clone)]
pub struct StakeUtxo {
/// `txhash#index`, suitable for citing as an input.
pub utxo_ref: String,
/// Decoded StakeDatum.
pub datum: StakeDatum,
/// Lovelace at this UTxO.
pub lovelace: u64,
/// Gov-token (TRP) quantity at this UTxO. Should equal
/// `datum.staked_amount` when the validator is correctly enforcing.
pub gov_token_quantity: u64,
}
/// 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,
}
/// Trait for the chain reads we need. Lets tests stub Koios responses
/// via fake implementations without spinning up a network.
#[async_trait::async_trait]
pub trait DaoReader: Send + Sync {
/// Return the singleton governor UTxO + decoded datum.
async fn get_governor(&self, cfg: &DaoConfig) -> DaoResult<(String, GovernorDatum)>;
/// Return all stakes for this DAO (filtered by gov-token policy).
async fn list_stakes(&self, cfg: &DaoConfig) -> DaoResult<Vec<StakeUtxo>>;
/// Return all proposals for this DAO.
async fn list_proposals(&self, cfg: &DaoConfig) -> DaoResult<Vec<ProposalUtxo>>;
}
// ---------------- Koios-backed implementation ------------------------------
/// Koios-backed `DaoReader`.
///
/// Phase 1 implementation. Talks to Koios's REST API directly because:
///
/// - We already use Koios elsewhere in aldabra (no new infra dep).
/// - Koios returns inline datums, not just hashes, in `address_info`.
/// - It's free + has no auth requirements + has community fallbacks.
///
/// The full `inline_datum.value` field of an address-info response
/// arrives as a JSON object whose shape matches Koios's "PlutusData
/// JSON" representation. Simpler path: ask Koios for the *hex CBOR*
/// of the datum and decode through `pallas_codec`.
pub struct KoiosDaoReader {
base_url: String,
http: reqwest::Client,
}
impl KoiosDaoReader {
/// Construct against a Koios base URL (e.g. `https://api.koios.rest/api/v1`
/// or `https://preprod.koios.rest/api/v1`).
pub fn new(base_url: impl Into<String>) -> Self {
Self::with_bearer(base_url, None)
}
/// Same as [`Self::new`] but with an optional `Authorization: Bearer
/// <token>` default header for paid-tier Koios access. Bearer is
/// supplied by the caller from `ALDABRA_KOIOS_BEARER` env var only.
pub fn with_bearer(base_url: impl Into<String>, bearer: Option<&str>) -> Self {
let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(30));
if let Some(token) = bearer {
let mut hdrs = reqwest::header::HeaderMap::new();
let value = format!("Bearer {token}");
let mut hv = reqwest::header::HeaderValue::from_str(&value)
.expect("ALDABRA_KOIOS_BEARER contains invalid header bytes");
hv.set_sensitive(true);
hdrs.insert(reqwest::header::AUTHORIZATION, hv);
builder = builder.default_headers(hdrs);
}
Self {
base_url: base_url.into(),
http: builder.build().expect("reqwest client"),
}
}
async fn address_info(&self, address: &str) -> DaoResult<Vec<KoiosAddressInfo>> {
let url = format!("{}/address_info", self.base_url);
let body = serde_json::json!({ "_addresses": [address] });
let resp = self
.http
.post(url)
.json(&body)
.send()
.await
.map_err(|e| DaoError::Backend(format!("address_info: {e}")))?;
if !resp.status().is_success() {
return Err(DaoError::Backend(format!(
"address_info: HTTP {}",
resp.status()
)));
}
resp.json::<Vec<KoiosAddressInfo>>()
.await
.map_err(|e| DaoError::Backend(format!("address_info parse: {e}")))
}
}
#[async_trait::async_trait]
impl DaoReader for KoiosDaoReader {
async fn get_governor(&self, cfg: &DaoConfig) -> DaoResult<(String, GovernorDatum)> {
let infos = self.address_info(&cfg.governor_addr).await?;
let utxos = infos
.into_iter()
.next()
.map(|i| i.utxo_set)
.unwrap_or_default();
// Governor is a singleton — there should be exactly one UTxO with
// an inline datum. Filter to ones with inline datums and pick the
// first; if there are none we bail with a state error rather than
// returning a partial result.
for u in utxos {
if let Some(d) = u.inline_datum.as_ref() {
let pd = decode_datum_cbor_hex(&d.bytes)?;
let datum = GovernorDatum::from_plutus_data(&pd)?;
let utxo_ref = format!("{}#{}", u.tx_hash, u.tx_index);
return Ok((utxo_ref, datum));
}
}
Err(DaoError::State(format!(
"no governor UTxO with inline datum at {}",
cfg.governor_addr
)))
}
async fn list_stakes(&self, cfg: &DaoConfig) -> DaoResult<Vec<StakeUtxo>> {
let infos = self.address_info(&cfg.stakes_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 {
// Filter to UTxOs that hold this DAO's gov token. Stakes
// address is shared across DAOs, so we discard any UTxO
// that doesn't carry our policy.
//
// `Option<Vec<_>>::iter()` yields the inner Vec once if Some,
// then `.flatten()` flattens to T. Works whether Koios returns
// asset_list as `null` (None) or `[]` (Some(empty)).
let mut gov_qty: u64 = 0;
for a in u.asset_list.as_ref().into_iter().flatten() {
if a.policy_id == cfg.gov_token_policy {
gov_qty = a.quantity.parse().unwrap_or(0);
break;
}
}
if gov_qty == 0 {
continue;
}
let Some(ref d) = u.inline_datum else {
continue;
};
let pd = match decode_datum_cbor_hex(&d.bytes) {
Ok(pd) => pd,
Err(_) => continue, // wrong shape; not our datum
};
let datum = match StakeDatum::from_plutus_data(&pd) {
Ok(d) => d,
Err(_) => continue,
};
out.push(StakeUtxo {
utxo_ref: format!("{}#{}", u.tx_hash, u.tx_index),
datum,
lovelace: u.value.parse().unwrap_or(0),
gov_token_quantity: gov_qty,
});
}
Ok(out)
}
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)
}
}
// ---------- Koios JSON shapes ---------------------------------------------
/// Subset of Koios's `address_info` response that we use.
#[derive(Debug, Deserialize)]
struct KoiosAddressInfo {
#[allow(dead_code)]
address: String,
utxo_set: Vec<KoiosUtxo>,
}
#[derive(Debug, Deserialize)]
struct KoiosUtxo {
tx_hash: String,
tx_index: u32,
/// Lovelace as a string (Koios convention for big numbers).
value: String,
#[serde(default)]
asset_list: Option<Vec<KoiosAsset>>,
#[serde(default)]
inline_datum: Option<KoiosInlineDatum>,
}
#[derive(Debug, Deserialize)]
struct KoiosAsset {
policy_id: String,
#[serde(default)]
asset_name: Option<String>,
quantity: String,
}
#[derive(Debug, Deserialize)]
struct KoiosInlineDatum {
/// CBOR-hex encoded PlutusData.
bytes: String,
#[allow(dead_code)]
#[serde(default)]
value: Option<serde_json::Value>,
}
/// Decode a CBOR-hex datum string into a typed `PlutusData`.
///
/// Uses the same path as `aldabra-core::cip68` round-trip tests:
/// `pallas_codec::minicbor::decode(&bytes)`.
fn decode_datum_cbor_hex(hex_str: &str) -> DaoResult<PlutusData> {
let bytes = hex::decode(hex_str).map_err(|e| DaoError::Cbor(format!("hex decode: {e}")))?;
pallas_codec::minicbor::decode::<PlutusData>(&bytes)
.map_err(|e| DaoError::Cbor(format!("plutus data decode: {e}")))
}