aldabra/crates/aldabra-dao/src/reader.rs
Sulkta 564ba3ccb5 chore: scrub internal session-log narrative from code comments
Wide sweep across the codebase to remove leftover artifacts of internal
development sessions, internal entity naming, and audit-code references
that point at non-public docs. The technical reasoning for each piece
of code stays; the "Caught 2026-05-XX while debugging XYZ at preprod"
narrative goes.

Categories scrubbed:
- Dated session-log comments ("Caught/Surfaced/Discovered 2026-05-XX")
  → rewritten as neutral technical reasoning.
- Internal audit codes (AUDIT-H2, AUDIT-C2, AUDIT-M2, AUDIT-H5, etc.)
  referencing a non-public audit doc → labels stripped, fix reasoning
  kept.
- Internal-entity names in code comments (Sulkta-specific, Sulkta runs
  X, Terrapin/TRP as gov-token names) → generic phrasing.
- Test fixture helper `sulkta_cfg` → `test_dao_cfg`; test DAO name
  string `"sulkta"` → `"test-dao"`. On-chain addresses in test fixtures
  kept (they're real-world wire-byte test data on public chain).
- Cross-references to memory files / non-public audit docs
  (`internal notes`, `aiken-escrow/README.md`)
  → reasoning inlined or removed.
- Test names renamed: `decodes_sulkta_live_governor_datum` →
  `decodes_live_governor_datum`, `decodes_sulkta_live_proposal_zero` →
  `decodes_live_finished_proposal`, etc.

Kept (legitimate):
- Cross-references to in-repo audit docs (aiken-escrow/README.md, aiken-escrow/README.md) — they ARE the
  public artifacts being referenced.
- HIGH-1/HIGH-2/MED-2/LOW labels on escrow fixes — these correspond to
  findings in the in-repo audit doc.
- TODO markers — legitimate work-still-to-do.
2026-05-10 21:29:40 -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 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}")))
}