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.
334 lines
12 KiB
Rust
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}")))
|
|
}
|