From 41195ece4f017c36f0e599f7dabd31c4af4ace15 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 5 May 2026 13:40:12 -0700 Subject: [PATCH] feat(dao): scaffold aldabra-dao crate (Phase 1 reads) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 4th workspace crate `aldabra-dao` for native Agora-on-Cardano DAO interaction. Multi-DAO from day one — DaoConfig per DAO at \$ALDABRA_DATA/daos/.json + .active selector. Sulkta DAO and any community Agora deployment (Bob's DAO, Alice's DAO) are first-class. Phase 0 type port complete: - StakeDatum, StakeRedeemer, ProposalAction, ProposalLock, Credential - ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalThresholds, ProposalTimingConfig, ProposalVotes - GovernorDatum, GovernorRedeemer - All Constr indices verified against Agora source makeIsDataIndexed + EnumIsData declarations (Stake/Proposal/Governor/Action/Status all cross-referenced) - Round-trip tests for every type Phase 1 read surface (this commit): - DaoStore: DaoConfig load/save/list/remove + active-DAO selector with first-register-becomes-active UX. 8 unit tests. - DaoReader trait + KoiosDaoReader impl for get_governor + list_stakes. list_proposals stubbed pending Phase 4 proposal-script-address discovery. - Stake address sharing handled: list_stakes filters on gov_token_policy (the shared MLabs stakes addr serves many DAOs). Stubs for upcoming phases: - agora/treasury.rs (Phase 4 — treasury spend helpers) - agora/authority_token.rs (Phase 4 — GAT mint/burn) - agora/reference_scripts.rs (Phase 2/3 — independent script-hash discovery per Cobb's choice 2026-05-05; computed locally, never trust MLabs registry) - builder/mod.rs (per-operation Plutus tx builders, populated phase-by-phase) Spec doc + decisions: memory/spec-aldabra-dao-agora-port.md in workspace. Effects map (`ProposalDatum.effects`) kept as raw PlutusData for round-trip integrity until Phase 4 (proposal create) needs typed access. ExUnits strategy locked: Koios tx_evaluate from day one (no hardcoded values). Wired up in Phase 2 alongside reference-script discovery. --- Cargo.toml | 8 +- crates/aldabra-dao/Cargo.toml | 79 ++++ .../aldabra-dao/src/agora/authority_token.rs | 20 + crates/aldabra-dao/src/agora/governor.rs | 115 +++++ crates/aldabra-dao/src/agora/mod.rs | 53 +++ crates/aldabra-dao/src/agora/plutus_data.rs | 204 +++++++++ crates/aldabra-dao/src/agora/proposal.rs | 362 ++++++++++++++++ .../src/agora/reference_scripts.rs | 37 ++ crates/aldabra-dao/src/agora/stake.rs | 357 +++++++++++++++ crates/aldabra-dao/src/agora/treasury.rs | 14 + crates/aldabra-dao/src/builder/mod.rs | 17 + crates/aldabra-dao/src/config.rs | 407 ++++++++++++++++++ crates/aldabra-dao/src/error.rs | 52 +++ crates/aldabra-dao/src/lib.rs | 35 ++ crates/aldabra-dao/src/reader.rs | 261 +++++++++++ 15 files changed, 2018 insertions(+), 3 deletions(-) create mode 100644 crates/aldabra-dao/Cargo.toml create mode 100644 crates/aldabra-dao/src/agora/authority_token.rs create mode 100644 crates/aldabra-dao/src/agora/governor.rs create mode 100644 crates/aldabra-dao/src/agora/mod.rs create mode 100644 crates/aldabra-dao/src/agora/plutus_data.rs create mode 100644 crates/aldabra-dao/src/agora/proposal.rs create mode 100644 crates/aldabra-dao/src/agora/reference_scripts.rs create mode 100644 crates/aldabra-dao/src/agora/stake.rs create mode 100644 crates/aldabra-dao/src/agora/treasury.rs create mode 100644 crates/aldabra-dao/src/builder/mod.rs create mode 100644 crates/aldabra-dao/src/config.rs create mode 100644 crates/aldabra-dao/src/error.rs create mode 100644 crates/aldabra-dao/src/lib.rs create mode 100644 crates/aldabra-dao/src/reader.rs diff --git a/Cargo.toml b/Cargo.toml index 44feacc..37a6908 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,17 @@ # Cargo workspace root for aldabra. # -# Three crates: +# Four crates: # aldabra-core — key derivation, signing, types, mnemonic handling # aldabra-chain — pluggable chain backends (Koios, Ogmios). Trait-first. -# aldabra-mcp — binary; the MCP server, glues core+chain together. +# aldabra-dao — Agora-on-Cardano DAO interaction; multi-DAO from day 1. +# aldabra-mcp — binary; the MCP server, glues core+chain+dao together. # # Named for the Aldabra giant tortoise (Aldabrachelys gigantea) — endemic # to the Aldabra atoll in the Seychelles, up to 250 kg, 150-year lifespan. # Long-lived, defended, slow but unstoppable. Fitting metaphor for a # wallet that holds your money. # -# Workspace deps are pinned here so all three crates use the same versions. +# Workspace deps are pinned here so all crates use the same versions. # Add a dep here, then reference it in each crate's Cargo.toml as # foo = { workspace = true } [workspace] @@ -18,6 +19,7 @@ resolver = "2" members = [ "crates/aldabra-core", "crates/aldabra-chain", + "crates/aldabra-dao", "crates/aldabra-mcp", ] diff --git a/crates/aldabra-dao/Cargo.toml b/crates/aldabra-dao/Cargo.toml new file mode 100644 index 0000000..1fe0df3 --- /dev/null +++ b/crates/aldabra-dao/Cargo.toml @@ -0,0 +1,79 @@ +# aldabra-dao — Agora-on-Cardano DAO interaction. +# +# This crate is a community-publishable, multi-DAO client for any +# Agora deployment. Bob's DAO and Alice's DAO are both first-class — +# nothing is hardcoded to any single DAO. +# +# Layout: +# config — per-DAO config files at $ALDABRA_DATA/daos/.json +# + .active selector. Loaded fresh on every tool call so +# add/remove/switch take effect without daemon restart. +# agora — Plutarch type ports (StakeDatum, ProposalDatum, etc) with +# PlutusData encode/decode. One module per Agora module. +# reader — Read-only Koios-backed state queries for governor / +# stakes / proposals UTxOs. Decodes datums into typed Rust. +# builder — Plutus tx assembly per operation (stake_create, +# proposal_vote, etc). Each operation is its own file +# for readability. +# error — Crate-internal error type. +# +# Boundary rules: +# - We depend on aldabra-core for crypto / signing / address ops only. +# - We depend on aldabra-chain for raw Koios queries. +# - We do NOT touch keys directly; signing is delegated to aldabra-core. +# - We do NOT do MCP. The dao_* MCP tools live in aldabra-mcp. +# +# Why a separate crate (not just a module under aldabra-core): +# - DAO ops are a separate auditable surface from the core wallet. +# - Community users can depend on aldabra-dao without pulling in the +# full MCP binary. +# - Plutus DAO tx assembly is enough code that mixing it with raw +# wallet sends would bloat aldabra-core past the auditability threshold. + +[package] +name = "aldabra-dao" +version.workspace = true +edition.workspace = true +license-file.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +aldabra-core = { path = "../aldabra-core" } +aldabra-chain = { path = "../aldabra-chain" } + +# Pallas — PlutusData encode/decode + tx building + addresses. +pallas-primitives = { workspace = true } +pallas-codec = { workspace = true } +pallas-crypto = { workspace = true } +pallas-addresses = { workspace = true } +pallas-txbuilder = { workspace = true } +pallas-traverse = { workspace = true } + +# Async + I/O for chain reads. +tokio = { workspace = true } +async-trait = "0.1" +reqwest = { workspace = true } + +# Serde for DaoConfig persistence + Koios JSON. +serde = { workspace = true } +serde_json = { workspace = true } + +# Bech32 for parsing addresses we don't get pre-decoded. +bech32 = "0.9" + +# Hex for handling token names + script hashes. +hex = "0.4" + +# Errors. +thiserror = { workspace = true } + +# Logging. +tracing = { workspace = true } + +[dev-dependencies] +# DaoStore tests use a temp dir as the data root. +tempfile = "3" +# `from_slice` for round-trip CBOR tests in agora module. +pallas-codec = { workspace = true } + diff --git a/crates/aldabra-dao/src/agora/authority_token.rs b/crates/aldabra-dao/src/agora/authority_token.rs new file mode 100644 index 0000000..bf3a0a7 --- /dev/null +++ b/crates/aldabra-dao/src/agora/authority_token.rs @@ -0,0 +1,20 @@ +//! Authority Token (GAT) helpers. +//! +//! Mirrors [`Agora.AuthorityToken`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/AuthorityToken.hs). +//! +//! GATs are one-shot capability tokens minted by the governor when a +//! proposal succeeds, and sent to that proposal's effect script(s). +//! Each effect script consumes + burns its GAT to authorize its effect +//! (typically: spend the treasury for a treasury-withdraw effect, or +//! mutate the governor for a parameter-change effect). +//! +//! ## Phase scope +//! +//! - **Phase 3 (vote)**: GATs are not minted yet — proposals are still +//! in Draft / VotingReady / Locked states. We don't touch this module. +//! - **Phase 4 (advance + execute)**: When `ProposalRedeemer::AdvanceProposal` +//! transitions a Locked proposal to Finished, the same tx must mint +//! one GAT per effect script. This module will provide +//! `build_gat_mint(...)` to assemble that. +//! +//! Empty for Phase 1; documentation anchor for the upcoming code. diff --git a/crates/aldabra-dao/src/agora/governor.rs b/crates/aldabra-dao/src/agora/governor.rs new file mode 100644 index 0000000..7ede990 --- /dev/null +++ b/crates/aldabra-dao/src/agora/governor.rs @@ -0,0 +1,115 @@ +//! Governor datum + redeemer. +//! +//! Mirrors [`Agora.Governor`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/Governor.hs). +//! +//! Each DAO has exactly one governor UTxO at its `governor_addr`. The +//! GovernorDatum carries the DAO's parameters (thresholds, timings) and +//! the proposal-id counter. + +use pallas_primitives::PlutusData; + +use crate::agora::plutus_data::{as_constr, as_int, constr, int}; +use crate::agora::proposal::{ProposalThresholds, ProposalTimingConfig}; +use crate::error::{DaoError, DaoResult}; + +/// `GovernorDatum` — ProductIsData → `Constr 0 [...]`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GovernorDatum { + pub proposal_thresholds: ProposalThresholds, + pub next_proposal_id: i64, + pub proposal_timings: ProposalTimingConfig, + /// `MaxTimeRangeWidth` newtype over POSIXTime — ms. + pub create_proposal_time_range_max_width: i64, + pub maximum_created_proposals_per_stake: i64, +} + +impl GovernorDatum { + pub fn to_plutus_data(&self) -> DaoResult { + Ok(constr( + 0, + vec![ + self.proposal_thresholds.to_plutus_data()?, + int(self.next_proposal_id as i128)?, + self.proposal_timings.to_plutus_data()?, + int(self.create_proposal_time_range_max_width as i128)?, + int(self.maximum_created_proposals_per_stake as i128)?, + ], + )) + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let (idx, fields) = as_constr(pd)?; + if idx != 0 || fields.len() != 5 { + return Err(DaoError::Datum(format!( + "GovernorDatum expects Constr 0 with 5 fields, got Constr {idx} with {}", + fields.len() + ))); + } + Ok(GovernorDatum { + proposal_thresholds: ProposalThresholds::from_plutus_data(&fields[0])?, + next_proposal_id: as_int(&fields[1])? as i64, + proposal_timings: ProposalTimingConfig::from_plutus_data(&fields[2])?, + create_proposal_time_range_max_width: as_int(&fields[3])? as i64, + maximum_created_proposals_per_stake: as_int(&fields[4])? as i64, + }) + } +} + +/// `GovernorRedeemer` — EnumIsData (declaration order): +/// 0=CreateProposal, 1=MintGATs, 2=MutateGovernor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GovernorRedeemer { + CreateProposal, + MintGATs, + MutateGovernor, +} + +impl GovernorRedeemer { + pub fn to_plutus_data(self) -> PlutusData { + constr(self as u64, vec![]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn governor_datum_round_trip() { + let g = GovernorDatum { + proposal_thresholds: ProposalThresholds { + execute: 50, + create: 100, + to_voting: 100, + vote: 1, + cosign: 10, + }, + next_proposal_id: 2, + proposal_timings: ProposalTimingConfig { + draft_time: 7 * 86400 * 1000, + voting_time: 7 * 86400 * 1000, + locking_time: 48 * 3600 * 1000, + executing_time: 24 * 3600 * 1000, + min_stake_voting_time: 60_000, + voting_time_range_max_width: 60_000, + }, + create_proposal_time_range_max_width: 60_000, + maximum_created_proposals_per_stake: 3, + }; + let pd = g.to_plutus_data().unwrap(); + assert_eq!(GovernorDatum::from_plutus_data(&pd).unwrap(), g); + } + + #[test] + fn governor_redeemer_indices() { + for (r, i) in [ + (GovernorRedeemer::CreateProposal, 0u64), + (GovernorRedeemer::MintGATs, 1), + (GovernorRedeemer::MutateGovernor, 2), + ] { + let pd = r.to_plutus_data(); + let (idx, _) = as_constr(&pd).unwrap(); + assert_eq!(idx, i); + } + } +} diff --git a/crates/aldabra-dao/src/agora/mod.rs b/crates/aldabra-dao/src/agora/mod.rs new file mode 100644 index 0000000..59a1f1f --- /dev/null +++ b/crates/aldabra-dao/src/agora/mod.rs @@ -0,0 +1,53 @@ +//! Agora type ports. +//! +//! Each submodule mirrors one Agora Haskell module +//! ([`Agora.Stake`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/Stake.hs), +//! [`Agora.Proposal`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/Proposal.hs), +//! etc) and exposes: +//! +//! - Plain Rust types matching the Haskell ADTs. +//! - `From<&T> for pallas_primitives::PlutusData` for encoding (writing +//! datums + redeemers into transactions we build). +//! - `TryFrom<&PlutusData> for T` for decoding (reading datums from +//! on-chain UTxOs). +//! +//! ## Encoding rules +//! +//! Agora's Plutarch types use one of three encoding shapes: +//! +//! | Shape | Plutus encoding | Used for | +//! |---------------------------|-----------------------------------|----------| +//! | `ProductIsData T` | `Constr 0 [field0, field1, ...]` | Records (StakeDatum, GovernorDatum, ProposalDatum, ProposalThresholds, ProposalTimingConfig, ProposalLock) | +//! | `EnumIsData T` | `Constr i []` | Plain enums (ProposalStatus) | +//! | `makeIsDataIndexed` | `Constr i [field0, ...]` | Sum types with payload (StakeRedeemer, ProposalRedeemer, ProposalAction, GovernorRedeemer) | +//! | newtype over `Integer` | `BigInt` | ProposalId, ResultTag, ProposalStartingTime, MaxTimeRangeWidth | +//! | newtype over `Map k v` | `Map [(k,v)]` | ProposalVotes, ProposalEffectGroup | +//! +//! The Constr indices for sum-type-with-payload variants come from +//! explicit `makeIsDataIndexed [('Foo, 0), ('Bar, 1), ...]` calls in +//! Agora's source. We MUST match those exactly — the validator +//! pattern-matches on Constr index and any mismatch is a flat +//! script failure. +//! +//! ## Common scaffolding +//! +//! Each submodule has its own types and impls; this module exposes +//! a few shared helpers ([`plutus_data`]) used across all of them. + +pub mod governor; +pub mod proposal; +pub mod stake; +pub mod treasury; + +pub mod authority_token; +pub mod plutus_data; +pub mod reference_scripts; + +pub use governor::{GovernorDatum, GovernorRedeemer}; +pub use proposal::{ + ProposalDatum, ProposalRedeemer, ProposalStatus, ProposalThresholds, ProposalTimingConfig, + ProposalVotes, +}; +pub use stake::{ + Credential, ProposalAction, ProposalLock, StakeDatum, StakeRedeemer, +}; diff --git a/crates/aldabra-dao/src/agora/plutus_data.rs b/crates/aldabra-dao/src/agora/plutus_data.rs new file mode 100644 index 0000000..78a2d49 --- /dev/null +++ b/crates/aldabra-dao/src/agora/plutus_data.rs @@ -0,0 +1,204 @@ +//! Shared PlutusData helpers for Agora type ports. +//! +//! Plutus's `Constr` tag encoding rule (CDDL): +//! +//! - For constructor index `i` ∈ `[0, 6]`: tag = `121 + i`, `any_constructor = None`. +//! - For `i` ∈ `[7, 127]`: tag = `1280 + (i - 7)`, `any_constructor = None`. +//! - For `i` >= 128: tag = `102`, `any_constructor = Some(i)`. +//! +//! All Agora sum types we care about have ≤ 4 variants, so we always +//! land in the first branch. We still implement the general form for +//! correctness — the cost is zero (one match arm). + +use pallas_codec::utils::MaybeIndefArray; +use pallas_primitives::{BigInt, BoundedBytes, Constr, PlutusData}; + +use crate::error::{DaoError, DaoResult}; + +/// Build a `Constr i [fields...]` PlutusData with the right tag rule. +/// +/// Returns the PlutusData ready to drop into a parent structure. +pub fn constr(index: u64, fields: Vec) -> PlutusData { + let (tag, any_constructor) = if index <= 6 { + (121 + index, None) + } else if index <= 127 { + (1280 + (index - 7), None) + } else { + (102, Some(index)) + }; + PlutusData::Constr(Constr { + tag, + any_constructor, + fields: MaybeIndefArray::Def(fields), + }) +} + +/// Encode a non-negative integer as PlutusData::BigInt. +/// +/// Agora uses `Integer` everywhere, but in practice all our numbers +/// (lovelace, token quantity, POSIX millis) fit well inside i128. If +/// a future field exceeds that we can swap to `BigInt::BigUInt` — +/// none currently do. +pub fn int(n: i128) -> DaoResult { + let i = i64::try_from(n).map_err(|_| { + DaoError::Datum(format!("integer {n} exceeds i64 — needs BigInt::Big{{U,N}}Int impl")) + })?; + Ok(PlutusData::BigInt(BigInt::Int(i.into()))) +} + +/// Encode a byte string as PlutusData::BoundedBytes. +pub fn bytes(bs: &[u8]) -> PlutusData { + PlutusData::BoundedBytes(BoundedBytes::from(bs.to_vec())) +} + +// ---------- decode helpers -------------------------------------------------- + +/// Inspect a PlutusData::Constr. Returns (constructor_index, fields). +pub fn as_constr(pd: &PlutusData) -> DaoResult<(u64, &Vec)> { + match pd { + PlutusData::Constr(c) => { + let idx = if let Some(i) = c.any_constructor { + i + } else if (121..=127).contains(&c.tag) { + c.tag - 121 + } else if (1280..=1400).contains(&c.tag) { + c.tag - 1280 + 7 + } else { + return Err(DaoError::Datum(format!( + "unknown Constr tag {} (no any_constructor)", + c.tag + ))); + }; + let MaybeIndefArray::Def(ref fields) | MaybeIndefArray::Indef(ref fields) = c.fields; + Ok((idx, fields)) + } + other => Err(DaoError::Datum(format!( + "expected Constr, got {other:?}" + ))), + } +} + +/// Decode a PlutusData integer into i128. +pub fn as_int(pd: &PlutusData) -> DaoResult { + match pd { + PlutusData::BigInt(BigInt::Int(i)) => { + // pallas's BigInt::Int wraps a `minicbor::data::Int` which lossily exposes i64 only. + Ok(i128::from(i64::from(*i))) + } + PlutusData::BigInt(BigInt::BigUInt(b)) => { + let bs = b.as_slice(); + if bs.len() > 16 { + return Err(DaoError::Datum("BigUInt exceeds 128 bits".into())); + } + let mut buf = [0u8; 16]; + buf[16 - bs.len()..].copy_from_slice(bs); + Ok(i128::from_be_bytes(buf).max(0)) + } + PlutusData::BigInt(BigInt::BigNInt(b)) => { + let bs = b.as_slice(); + if bs.len() > 16 { + return Err(DaoError::Datum("BigNInt exceeds 128 bits".into())); + } + let mut buf = [0u8; 16]; + buf[16 - bs.len()..].copy_from_slice(bs); + // Plutus BigNInt is "negative of (n+1)" per CBOR convention. + let n = i128::from_be_bytes(buf); + Ok(-n - 1) + } + other => Err(DaoError::Datum(format!( + "expected BigInt, got {other:?}" + ))), + } +} + +/// Decode a PlutusData::BoundedBytes into a Vec. +pub fn as_bytes(pd: &PlutusData) -> DaoResult> { + match pd { + PlutusData::BoundedBytes(b) => Ok(b.as_ref().clone()), + other => Err(DaoError::Datum(format!( + "expected BoundedBytes, got {other:?}" + ))), + } +} + +/// Decode a PlutusData::Array (works for both Def and Indef encodings). +pub fn as_array(pd: &PlutusData) -> DaoResult<&Vec> { + match pd { + PlutusData::Array(MaybeIndefArray::Def(v)) | PlutusData::Array(MaybeIndefArray::Indef(v)) => { + Ok(v) + } + other => Err(DaoError::Datum(format!( + "expected Array, got {other:?}" + ))), + } +} + +/// Decode a PlutusData::Map into a vector of (key, value) refs, preserving order. +pub fn as_map(pd: &PlutusData) -> DaoResult> { + match pd { + PlutusData::Map(kvp) => Ok(kvp.iter().map(|(k, v)| (k, v)).collect()), + other => Err(DaoError::Datum(format!( + "expected Map, got {other:?}" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constr_low_index_uses_121_plus_i() { + let pd = constr(0, vec![]); + if let PlutusData::Constr(c) = pd { + assert_eq!(c.tag, 121); + assert!(c.any_constructor.is_none()); + } else { + panic!("expected Constr"); + } + } + + #[test] + fn constr_index_6_is_127() { + let pd = constr(6, vec![]); + if let PlutusData::Constr(c) = pd { + assert_eq!(c.tag, 127); + } else { + panic!(); + } + } + + #[test] + fn constr_index_7_uses_1280_path() { + let pd = constr(7, vec![]); + if let PlutusData::Constr(c) = pd { + assert_eq!(c.tag, 1280); + } else { + panic!(); + } + } + + #[test] + fn round_trip_constr_index() { + for i in [0u64, 1, 5, 6, 7, 8, 100, 127] { + let pd = constr(i, vec![]); + let (decoded, _fields) = as_constr(&pd).unwrap(); + assert_eq!(decoded, i, "round-trip Constr index {i}"); + } + } + + #[test] + fn round_trip_int() { + for n in [0i128, 1, -1, 100, -100, i64::MAX as i128, i64::MIN as i128] { + let pd = int(n).unwrap(); + assert_eq!(as_int(&pd).unwrap(), n, "round-trip int {n}"); + } + } + + #[test] + fn round_trip_bytes() { + let bs: &[u8] = &[0xde, 0xad, 0xbe, 0xef]; + let pd = bytes(bs); + assert_eq!(as_bytes(&pd).unwrap(), bs); + } +} diff --git a/crates/aldabra-dao/src/agora/proposal.rs b/crates/aldabra-dao/src/agora/proposal.rs new file mode 100644 index 0000000..4614454 --- /dev/null +++ b/crates/aldabra-dao/src/agora/proposal.rs @@ -0,0 +1,362 @@ +//! Proposal datum + redeemer. +//! +//! Mirrors [`Agora.Proposal`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/Proposal.hs). +//! +//! ## Effects map (deferred) +//! +//! `ProposalDatum.effects` is a `Map ResultTag (Map ScriptHash ProposalEffectMetadata)`. +//! Effect metadata isn't needed for **voting** (Phase 3) — only for +//! **proposal creation** (Phase 4) and **proposal advance** (when minting GATs). +//! For Phase 1/3 we decode it into a typed shape but treat it as opaque +//! when re-encoding (round-trip preserves bytes via PlutusData). +//! +//! Concretely we expose [`ProposalDatum::effects_raw`] as the raw +//! PlutusData. Phase 4 will replace it with a typed `EffectsMap`. + +use pallas_codec::utils::{KeyValuePairs, MaybeIndefArray}; +use pallas_primitives::PlutusData; + +use crate::agora::plutus_data::{ + as_array, as_constr, as_int, as_map, constr, int, +}; +use crate::agora::stake::Credential; +use crate::error::{DaoError, DaoResult}; + +/// `data ProposalStatus = Draft | VotingReady | Locked | Finished` +/// via `EnumIsData` → `Constr i []` for `i` ∈ `[0,3]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProposalStatus { + Draft, + VotingReady, + Locked, + Finished, +} + +impl ProposalStatus { + pub fn to_plutus_data(self) -> PlutusData { + constr(self as u64, vec![]) + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let (idx, fields) = as_constr(pd)?; + if !fields.is_empty() { + return Err(DaoError::Datum(format!( + "ProposalStatus expects 0 fields, got {}", + fields.len() + ))); + } + Ok(match idx { + 0 => ProposalStatus::Draft, + 1 => ProposalStatus::VotingReady, + 2 => ProposalStatus::Locked, + 3 => ProposalStatus::Finished, + other => { + return Err(DaoError::Datum(format!( + "ProposalStatus expects 0..=3, got {other}" + ))) + } + }) + } +} + +/// `ProposalThresholds` — ProductIsData → `Constr 0 [execute, create, toVoting, vote, cosign]`. +/// +/// All fields are `Tagged GTTag Integer` upstream but on the wire they're plain Integer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposalThresholds { + pub execute: i64, + pub create: i64, + pub to_voting: i64, + pub vote: i64, + pub cosign: i64, +} + +impl ProposalThresholds { + pub fn to_plutus_data(&self) -> DaoResult { + Ok(constr( + 0, + vec![ + int(self.execute as i128)?, + int(self.create as i128)?, + int(self.to_voting as i128)?, + int(self.vote as i128)?, + int(self.cosign as i128)?, + ], + )) + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let (idx, fields) = as_constr(pd)?; + if idx != 0 || fields.len() != 5 { + return Err(DaoError::Datum(format!( + "ProposalThresholds expects Constr 0 with 5 fields, got Constr {idx} with {}", + fields.len() + ))); + } + Ok(ProposalThresholds { + execute: as_int(&fields[0])? as i64, + create: as_int(&fields[1])? as i64, + to_voting: as_int(&fields[2])? as i64, + vote: as_int(&fields[3])? as i64, + cosign: as_int(&fields[4])? as i64, + }) + } +} + +/// `ProposalTimingConfig` — ProductIsData → `Constr 0 [...]`. +/// +/// All fields are `POSIXTime` (milliseconds) except `votingTimeRangeMaxWidth` +/// which is `MaxTimeRangeWidth` (newtype over POSIXTime); on the wire both +/// encode as plain Integer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposalTimingConfig { + pub draft_time: i64, + pub voting_time: i64, + pub locking_time: i64, + pub executing_time: i64, + pub min_stake_voting_time: i64, + pub voting_time_range_max_width: i64, +} + +impl ProposalTimingConfig { + pub fn to_plutus_data(&self) -> DaoResult { + Ok(constr( + 0, + vec![ + int(self.draft_time as i128)?, + int(self.voting_time as i128)?, + int(self.locking_time as i128)?, + int(self.executing_time as i128)?, + int(self.min_stake_voting_time as i128)?, + int(self.voting_time_range_max_width as i128)?, + ], + )) + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let (idx, fields) = as_constr(pd)?; + if idx != 0 || fields.len() != 6 { + return Err(DaoError::Datum(format!( + "ProposalTimingConfig expects Constr 0 with 6 fields, got Constr {idx} with {}", + fields.len() + ))); + } + Ok(ProposalTimingConfig { + draft_time: as_int(&fields[0])? as i64, + voting_time: as_int(&fields[1])? as i64, + locking_time: as_int(&fields[2])? as i64, + executing_time: as_int(&fields[3])? as i64, + min_stake_voting_time: as_int(&fields[4])? as i64, + voting_time_range_max_width: as_int(&fields[5])? as i64, + }) + } +} + +/// `ProposalVotes = Map ResultTag Integer` — newtype over Map; encodes as a Plutus Map. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposalVotes(pub Vec<(i64, i64)>); + +impl ProposalVotes { + pub fn to_plutus_data(&self) -> DaoResult { + let pairs = self + .0 + .iter() + .map(|(k, v)| Ok((int(*k as i128)?, int(*v as i128)?))) + .collect::>>()?; + Ok(PlutusData::Map(KeyValuePairs::from(pairs))) + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let entries = as_map(pd)?; + let out = entries + .into_iter() + .map(|(k, v)| Ok((as_int(k)? as i64, as_int(v)? as i64))) + .collect::>>()?; + Ok(ProposalVotes(out)) + } +} + +/// `ProposalDatum` — ProductIsData → `Constr 0 [...]`. +/// +/// `effects` and `cosigners` are kept as raw PlutusData / typed Vec for +/// Phase 1 reads. For Phase 4 (proposal create) we'll replace `effects_raw` +/// with a typed `EffectsMap`. +#[derive(Debug, Clone, PartialEq)] +pub struct ProposalDatum { + pub proposal_id: i64, + /// Map ResultTag (Map ScriptHash ProposalEffectMetadata). + /// Opaque for Phase 1; kept as PlutusData for round-trip integrity. + pub effects_raw: PlutusData, + pub status: ProposalStatus, + pub cosigners: Vec, + pub thresholds: ProposalThresholds, + pub votes: ProposalVotes, + pub timing_config: ProposalTimingConfig, + /// `ProposalStartingTime` newtype → encodes as plain Integer (POSIXTime ms). + pub starting_time: i64, +} + +impl ProposalDatum { + pub fn to_plutus_data(&self) -> DaoResult { + let cosigners_pd: Vec = self + .cosigners + .iter() + .map(|c| c.to_plutus_data()) + .collect(); + Ok(constr( + 0, + vec![ + int(self.proposal_id as i128)?, + self.effects_raw.clone(), + self.status.to_plutus_data(), + PlutusData::Array(MaybeIndefArray::Def(cosigners_pd)), + self.thresholds.to_plutus_data()?, + self.votes.to_plutus_data()?, + self.timing_config.to_plutus_data()?, + int(self.starting_time as i128)?, + ], + )) + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let (idx, fields) = as_constr(pd)?; + if idx != 0 || fields.len() != 8 { + return Err(DaoError::Datum(format!( + "ProposalDatum expects Constr 0 with 8 fields, got Constr {idx} with {}", + fields.len() + ))); + } + Ok(ProposalDatum { + proposal_id: as_int(&fields[0])? as i64, + effects_raw: fields[1].clone(), + status: ProposalStatus::from_plutus_data(&fields[2])?, + cosigners: as_array(&fields[3])? + .iter() + .map(Credential::from_plutus_data) + .collect::>>()?, + thresholds: ProposalThresholds::from_plutus_data(&fields[4])?, + votes: ProposalVotes::from_plutus_data(&fields[5])?, + timing_config: ProposalTimingConfig::from_plutus_data(&fields[6])?, + starting_time: as_int(&fields[7])? as i64, + }) + } +} + +/// `ProposalRedeemer` — `makeIsDataIndexed`: +/// 0=Vote ResultTag, 1=Cosign, 2=UnlockStake, 3=AdvanceProposal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProposalRedeemer { + Vote(i64), + Cosign, + UnlockStake, + AdvanceProposal, +} + +impl ProposalRedeemer { + pub fn to_plutus_data(&self) -> DaoResult { + Ok(match self { + ProposalRedeemer::Vote(tag) => constr(0, vec![int(*tag as i128)?]), + ProposalRedeemer::Cosign => constr(1, vec![]), + ProposalRedeemer::UnlockStake => constr(2, vec![]), + ProposalRedeemer::AdvanceProposal => constr(3, vec![]), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn proposal_status_indices() { + for (s, i) in [ + (ProposalStatus::Draft, 0u64), + (ProposalStatus::VotingReady, 1), + (ProposalStatus::Locked, 2), + (ProposalStatus::Finished, 3), + ] { + let pd = s.to_plutus_data(); + let (idx, _) = as_constr(&pd).unwrap(); + assert_eq!(idx, i); + assert_eq!(ProposalStatus::from_plutus_data(&pd).unwrap(), s); + } + } + + #[test] + fn thresholds_round_trip() { + let t = ProposalThresholds { + execute: 50, + create: 100, + to_voting: 100, + vote: 1, + cosign: 10, + }; + let pd = t.to_plutus_data().unwrap(); + assert_eq!(ProposalThresholds::from_plutus_data(&pd).unwrap(), t); + } + + #[test] + fn timing_round_trip() { + let tc = ProposalTimingConfig { + draft_time: 7 * 86400 * 1000, + voting_time: 7 * 86400 * 1000, + locking_time: 48 * 3600 * 1000, + executing_time: 24 * 3600 * 1000, + min_stake_voting_time: 60_000, + voting_time_range_max_width: 60_000, + }; + let pd = tc.to_plutus_data().unwrap(); + assert_eq!(ProposalTimingConfig::from_plutus_data(&pd).unwrap(), tc); + } + + #[test] + fn votes_round_trip() { + let v = ProposalVotes(vec![(0, 50), (1, 0)]); + let pd = v.to_plutus_data().unwrap(); + assert_eq!(ProposalVotes::from_plutus_data(&pd).unwrap(), v); + } + + #[test] + fn proposal_redeemer_indices() { + for (r, i) in [ + (ProposalRedeemer::Vote(0), 0u64), + (ProposalRedeemer::Cosign, 1), + (ProposalRedeemer::UnlockStake, 2), + (ProposalRedeemer::AdvanceProposal, 3), + ] { + let pd = r.to_plutus_data().unwrap(); + let (idx, _) = as_constr(&pd).unwrap(); + assert_eq!(idx, i); + } + } + + #[test] + fn proposal_datum_round_trip_minimal() { + let pd_unit = constr(0, vec![]); // opaque effects placeholder + let datum = ProposalDatum { + proposal_id: 1, + effects_raw: pd_unit, + status: ProposalStatus::Draft, + cosigners: vec![Credential::PubKey(vec![0u8; 28])], + thresholds: ProposalThresholds { + execute: 50, + create: 100, + to_voting: 100, + vote: 1, + cosign: 10, + }, + votes: ProposalVotes(vec![(0, 0), (1, 0)]), + timing_config: ProposalTimingConfig { + draft_time: 1, + voting_time: 2, + locking_time: 3, + executing_time: 4, + min_stake_voting_time: 5, + voting_time_range_max_width: 6, + }, + starting_time: 1_700_000_000_000, + }; + let pd = datum.to_plutus_data().unwrap(); + assert_eq!(ProposalDatum::from_plutus_data(&pd).unwrap(), datum); + } +} diff --git a/crates/aldabra-dao/src/agora/reference_scripts.rs b/crates/aldabra-dao/src/agora/reference_scripts.rs new file mode 100644 index 0000000..beb3223 --- /dev/null +++ b/crates/aldabra-dao/src/agora/reference_scripts.rs @@ -0,0 +1,37 @@ +//! Locate Agora reference-script UTxOs on chain. +//! +//! Agora's compiled validators and minting policies are too big to inline +//! in every transaction (~16 KB each). Cardano's solution is **reference +//! inputs**: a UTxO can carry a `reference_script` payload, and any other +//! tx can cite that UTxO to use its script bytes without paying the size +//! cost again. +//! +//! For Sulkta's DAO (and any other Agora deployment), the reference UTxOs +//! sit at the shared stakes script address: 268+ UTxOs each carrying a +//! different Agora-related script in `reference_script`. We need to find: +//! +//! - The stake validator (lives at `stakes_addr`'s payment-credential) +//! - The proposal validator (lives at the proposal script address — derived +//! from same Agora deployment) +//! - The governor validator (lives at `governor_addr`'s payment-cred) +//! - The treasury validator (lives at `treasury_addr`'s payment-cred) +//! - The stake-state-thread minting policy (parameterized by gov token) +//! - The proposal-state-thread minting policy +//! - The GAT minting policy +//! +//! ## Compute-ourselves discovery (Cobb's pick 2026-05-05) +//! +//! Per the spec, we don't trust MLabs's published registry. Instead: +//! +//! 1. Decode each contract address (governor / stakes / treasury) to +//! extract its payment-credential script hash. +//! 2. Query Koios for all UTxOs at the stakes_addr (the shared deploy spot). +//! 3. For each UTxO with a `reference_script.hash`, match against: +//! - The 4 validator script hashes from step 1 +//! - The 3 minting-policy script hashes (computed in Phase 4 from +//! Agora source CBOR bytes; for Phase 1 reads we don't need them) +//! 4. Cache the (script_purpose, ref_utxo) mapping in memory; refresh on +//! cache miss. +//! +//! This module is empty for Phase 1 (read-only doesn't need refs). +//! Phase 2 (stake create) and Phase 3 (vote) need it; populated then. diff --git a/crates/aldabra-dao/src/agora/stake.rs b/crates/aldabra-dao/src/agora/stake.rs new file mode 100644 index 0000000..7320254 --- /dev/null +++ b/crates/aldabra-dao/src/agora/stake.rs @@ -0,0 +1,357 @@ +//! Stake datum + redeemer. +//! +//! Mirrors [`Agora.Stake`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/Stake.hs). +//! +//! On chain, each user's stake is one UTxO at the stakes script address +//! holding: +//! +//! - The locked governance tokens (in the value). +//! - A `StakeDatum` (inline datum) carrying owner / delegation / vote-locks. +//! - A "stake state thread" token from the Agora stake-policy minting policy +//! (proves the UTxO is a real stake, not someone sending TRP to the +//! address by accident). +//! +//! This module is the type port + encode/decode only. Tx assembly lives +//! in [`crate::builder`]. + +use pallas_codec::utils::MaybeIndefArray; +use pallas_primitives::PlutusData; + +use crate::agora::plutus_data::{as_array, as_bytes, as_constr, as_int, bytes, constr, int}; +use crate::error::{DaoError, DaoResult}; + +/// Cardano credential — either a public-key hash or a script hash. +/// +/// Mirrors `PlutusLedgerApi.V1.Credential.Credential`. Encoding: +/// +/// - `PubKeyCredential pkh` → `Constr 0 [bytes pkh]` +/// - `ScriptCredential sh` → `Constr 1 [bytes sh]` +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Credential { + PubKey(Vec), + Script(Vec), +} + +impl Credential { + pub fn to_plutus_data(&self) -> PlutusData { + match self { + Credential::PubKey(h) => constr(0, vec![bytes(h)]), + Credential::Script(h) => constr(1, vec![bytes(h)]), + } + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let (idx, fields) = as_constr(pd)?; + if fields.len() != 1 { + return Err(DaoError::Datum(format!( + "Credential expects 1 field, got {}", + fields.len() + ))); + } + let h = as_bytes(&fields[0])?; + match idx { + 0 => Ok(Credential::PubKey(h)), + 1 => Ok(Credential::Script(h)), + other => Err(DaoError::Datum(format!( + "Credential expects Constr 0 or 1, got {other}" + ))), + } + } +} + +/// What the stake was used for. +/// +/// Per `Agora.Stake.ProposalAction` — `makeIsDataIndexed` order: +/// - `Created` → `Constr 0 []` +/// - `Voted tag time` → `Constr 1 [int tag, int posix_time]` +/// - `Cosigned` → `Constr 2 []` +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProposalAction { + Created, + Voted { result_tag: i64, posix_time: i64 }, + Cosigned, +} + +impl ProposalAction { + pub fn to_plutus_data(&self) -> DaoResult { + Ok(match self { + ProposalAction::Created => constr(0, vec![]), + ProposalAction::Voted { result_tag, posix_time } => constr( + 1, + vec![int(*result_tag as i128)?, int(*posix_time as i128)?], + ), + ProposalAction::Cosigned => constr(2, vec![]), + }) + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let (idx, fields) = as_constr(pd)?; + Ok(match idx { + 0 => { + if !fields.is_empty() { + return Err(DaoError::Datum( + "ProposalAction::Created expects 0 fields".into(), + )); + } + ProposalAction::Created + } + 1 => { + if fields.len() != 2 { + return Err(DaoError::Datum(format!( + "ProposalAction::Voted expects 2 fields, got {}", + fields.len() + ))); + } + let result_tag = as_int(&fields[0])? as i64; + let posix_time = as_int(&fields[1])? as i64; + ProposalAction::Voted { result_tag, posix_time } + } + 2 => { + if !fields.is_empty() { + return Err(DaoError::Datum( + "ProposalAction::Cosigned expects 0 fields".into(), + )); + } + ProposalAction::Cosigned + } + other => { + return Err(DaoError::Datum(format!( + "ProposalAction expects Constr 0/1/2, got {other}" + ))) + } + }) + } +} + +/// One row in `StakeDatum.lockedBy`. +/// +/// `ProposalLock { proposalId :: ProposalId, action :: ProposalAction }` +/// → `Constr 0 [int proposal_id, action]` (ProductIsData encoding). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposalLock { + pub proposal_id: i64, + pub action: ProposalAction, +} + +impl ProposalLock { + pub fn to_plutus_data(&self) -> DaoResult { + Ok(constr( + 0, + vec![int(self.proposal_id as i128)?, self.action.to_plutus_data()?], + )) + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let (idx, fields) = as_constr(pd)?; + if idx != 0 || fields.len() != 2 { + return Err(DaoError::Datum(format!( + "ProposalLock expects Constr 0 [Int, ProposalAction], got Constr {idx} with {} fields", + fields.len() + ))); + } + let proposal_id = as_int(&fields[0])? as i64; + let action = ProposalAction::from_plutus_data(&fields[1])?; + Ok(ProposalLock { proposal_id, action }) + } +} + +/// `StakeDatum` — the inline datum on a stake UTxO. +/// +/// Encoding (ProductIsData → `Constr 0 [...]`): +/// +/// `Constr 0 [int staked_amount, owner_credential, maybe_delegated_to, [proposal_locks...]]` +/// +/// `Maybe Credential` encoding (Plutus `Maybe`): +/// - `Just c` → `Constr 0 [c]` +/// - `Nothing` → `Constr 1 []` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StakeDatum { + /// Amount of governance token (TRP) locked. Voting weight. + pub staked_amount: i64, + /// Stake owner; only this credential may move/destroy this stake. + pub owner: Credential, + /// Optional: another credential allowed to vote with this stake. + pub delegated_to: Option, + /// Active vote / cosign / create locks. Must be empty to deposit/withdraw. + pub locked_by: Vec, +} + +impl StakeDatum { + pub fn to_plutus_data(&self) -> DaoResult { + let delegated_pd = match &self.delegated_to { + Some(c) => constr(0, vec![c.to_plutus_data()]), + None => constr(1, vec![]), + }; + let locks_pd: Vec = self + .locked_by + .iter() + .map(|l| l.to_plutus_data()) + .collect::>>()?; + Ok(constr( + 0, + vec![ + int(self.staked_amount as i128)?, + self.owner.to_plutus_data(), + delegated_pd, + PlutusData::Array(MaybeIndefArray::Def(locks_pd)), + ], + )) + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let (idx, fields) = as_constr(pd)?; + if idx != 0 || fields.len() != 4 { + return Err(DaoError::Datum(format!( + "StakeDatum expects Constr 0 [Int, Credential, Maybe Credential, [ProposalLock]], \ + got Constr {idx} with {} fields", + fields.len() + ))); + } + let staked_amount = as_int(&fields[0])? as i64; + let owner = Credential::from_plutus_data(&fields[1])?; + let delegated_to = { + let (j, f) = as_constr(&fields[2])?; + match (j, f.len()) { + (0, 1) => Some(Credential::from_plutus_data(&f[0])?), + (1, 0) => None, + _ => { + return Err(DaoError::Datum(format!( + "Maybe expects Constr 0[1] | 1[0], got Constr {j} with {} fields", + f.len() + ))) + } + } + }; + let locked_by = as_array(&fields[3])? + .iter() + .map(ProposalLock::from_plutus_data) + .collect::>>()?; + Ok(StakeDatum { + staked_amount, + owner, + delegated_to, + locked_by, + }) + } +} + +/// `StakeRedeemer` — `makeIsDataIndexed` order: +/// 0=DepositWithdraw 1=Destroy 2=PermitVote 3=RetractVotes +/// 4=DelegateTo 5=ClearDelegate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StakeRedeemer { + /// Deposit (positive) or withdraw (negative) GT. Stake must be unlocked. + DepositWithdraw(i64), + /// Destroy the stake, returning all GT to the wallet. Must be unlocked. + Destroy, + /// Permit a vote to be added (used in conjunction with the Proposal-Vote + /// path; this redeemer goes on the stake input). + PermitVote, + /// Retract previously-cast votes from finished or in-progress proposals. + RetractVotes, + /// Delegate this stake's voting power to another credential. + DelegateTo(Credential), + /// Clear an existing delegation. + ClearDelegate, +} + +impl StakeRedeemer { + pub fn to_plutus_data(&self) -> DaoResult { + Ok(match self { + StakeRedeemer::DepositWithdraw(n) => constr(0, vec![int(*n as i128)?]), + StakeRedeemer::Destroy => constr(1, vec![]), + StakeRedeemer::PermitVote => constr(2, vec![]), + StakeRedeemer::RetractVotes => constr(3, vec![]), + StakeRedeemer::DelegateTo(c) => constr(4, vec![c.to_plutus_data()]), + StakeRedeemer::ClearDelegate => constr(5, vec![]), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pkh() -> Vec { + hex::decode("84d08bace7a5f23591d80e91dccd43fa3ea55a8d974f208842b6f2f3").unwrap() + } + + #[test] + fn credential_round_trip() { + let c = Credential::PubKey(pkh()); + let pd = c.to_plutus_data(); + assert_eq!(Credential::from_plutus_data(&pd).unwrap(), c); + + let s = Credential::Script(vec![0u8; 28]); + let pd = s.to_plutus_data(); + assert_eq!(Credential::from_plutus_data(&pd).unwrap(), s); + } + + #[test] + fn proposal_action_round_trip() { + for a in [ + ProposalAction::Created, + ProposalAction::Voted { + result_tag: 0, + posix_time: 1_700_000_000_000, + }, + ProposalAction::Cosigned, + ] { + let pd = a.to_plutus_data().unwrap(); + assert_eq!(ProposalAction::from_plutus_data(&pd).unwrap(), a); + } + } + + #[test] + fn stake_datum_round_trip_no_locks() { + let s = StakeDatum { + staked_amount: 50, + owner: Credential::PubKey(pkh()), + delegated_to: None, + locked_by: vec![], + }; + let pd = s.to_plutus_data().unwrap(); + assert_eq!(StakeDatum::from_plutus_data(&pd).unwrap(), s); + } + + #[test] + fn stake_datum_round_trip_with_lock_and_delegation() { + let s = StakeDatum { + staked_amount: 100, + owner: Credential::PubKey(pkh()), + delegated_to: Some(Credential::PubKey(vec![0xaa; 28])), + locked_by: vec![ + ProposalLock { + proposal_id: 1, + action: ProposalAction::Voted { + result_tag: 0, + posix_time: 1_700_000_000_000, + }, + }, + ProposalLock { + proposal_id: 2, + action: ProposalAction::Created, + }, + ], + }; + let pd = s.to_plutus_data().unwrap(); + assert_eq!(StakeDatum::from_plutus_data(&pd).unwrap(), s); + } + + #[test] + fn stake_redeemer_indices_match_make_is_data_indexed() { + let cases = [ + (StakeRedeemer::DepositWithdraw(0), 0), + (StakeRedeemer::Destroy, 1), + (StakeRedeemer::PermitVote, 2), + (StakeRedeemer::RetractVotes, 3), + (StakeRedeemer::DelegateTo(Credential::PubKey(vec![0u8; 28])), 4), + (StakeRedeemer::ClearDelegate, 5), + ]; + for (r, expected_idx) in cases { + let pd = r.to_plutus_data().unwrap(); + let (idx, _) = as_constr(&pd).unwrap(); + assert_eq!(idx, expected_idx, "{:?}", r); + } + } +} diff --git a/crates/aldabra-dao/src/agora/treasury.rs b/crates/aldabra-dao/src/agora/treasury.rs new file mode 100644 index 0000000..c9fa9f1 --- /dev/null +++ b/crates/aldabra-dao/src/agora/treasury.rs @@ -0,0 +1,14 @@ +//! Treasury — no datum, no redeemer. +//! +//! Per [`Agora.Treasury`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/Treasury.hs): +//! the treasury validator's only check is *"a single authority token (GAT) +//! has been burned in this transaction"*. No datum is read, no redeemer +//! is interpreted — the validator literally accepts any spend that +//! satisfies that one condition. +//! +//! Tx-shape helpers for spending the treasury (i.e. proposal-execution) +//! land in [`crate::builder`] when Phase 4 ships. This module is a +//! placeholder and a documentation anchor. + +// Intentionally empty for now; populated in Phase 4 with helpers like: +// pub fn build_treasury_spend(...) -> DaoResult diff --git a/crates/aldabra-dao/src/builder/mod.rs b/crates/aldabra-dao/src/builder/mod.rs new file mode 100644 index 0000000..2c9b1cd --- /dev/null +++ b/crates/aldabra-dao/src/builder/mod.rs @@ -0,0 +1,17 @@ +//! Plutus tx builders for DAO operations. +//! +//! Each operation lives in its own submodule so signing paths are +//! auditable in isolation. Naming convention matches the MCP tool +//! surface: `stake_create`, `proposal_vote`, etc. +//! +//! Phase scope (mirrors [`crate`] phase plan): +//! +//! | Phase | Module | What it ships | +//! |-------|-----------------------|---------------| +//! | 2 | `stake_create` | Lock TRP at stakes script with fresh StakeDatum | +//! | 3 | `proposal_vote` | Spend stake (PermitVote) + proposal (Vote tag) | +//! | 4 | `proposal_create` | Spend governor (CreateProposal), mint proposal-ST | +//! | 4 | `proposal_advance` | State-machine transition redeemer | +//! | 4 | `stake_destroy` | Spend stake (Destroy), return TRP to wallet | +//! +//! Empty for Phase 1; populated as each phase lands. diff --git a/crates/aldabra-dao/src/config.rs b/crates/aldabra-dao/src/config.rs new file mode 100644 index 0000000..82b7a5c --- /dev/null +++ b/crates/aldabra-dao/src/config.rs @@ -0,0 +1,407 @@ +//! Per-DAO config files and active-DAO selector. +//! +//! ## Layout on disk +//! +//! ```text +//! $ALDABRA_DATA/ +//! daos/ +//! sulkta.json ← named DAO config files +//! bobs_dao.json +//! alices_dao.json +//! .active ← regular file containing the active DAO name +//! ``` +//! +//! `.active` is a regular text file (not a symlink) for portability across +//! filesystems and to avoid confusing tools that follow symlinks. +//! +//! ## Adding a DAO +//! +//! Two paths: +//! +//! 1. Manual — caller supplies every field of [`DaoConfig`] (governor +//! address, gov-token policy, etc) and we save it. +//! 2. From-chain bootstrap — caller supplies the DAO's `initial_spend` +//! txhash#index, we fetch the governor UTxO, decode the datum, infer +//! the rest. See [`crate::reader::bootstrap_from_chain`]. This is +//! the friendly path; the manual path is for cold-bootstrap or +//! pre-genesis configuration. +//! +//! ## Why files, not env vars +//! +//! Multi-DAO would be unwieldy as env vars. Files let us hot-swap which +//! DAO is "active" without restarting the daemon, and let users version- +//! control their DAO list if they want to. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::error::{DaoError, DaoResult}; + +/// Cardano network the DAO lives on. +/// +/// We keep our own `Network` mirror rather than re-export +/// `aldabra_core::Network` because `DaoConfig` is serialized as JSON; +/// having a stable wire type owned by this crate prevents config-file +/// breakage if the core crate's Network enum gains variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DaoNetwork { + Mainnet, + Preprod, + Preview, +} + +impl Default for DaoNetwork { + fn default() -> Self { + Self::Mainnet + } +} + +/// One named DAO. Captures every Sulkta-specific value as an +/// instance field so the rest of the crate is config-driven. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DaoConfig { + /// Human-friendly label. Also the basename of the config file + /// (`.json`). Constrained to `[a-z0-9_-]+` for filesystem + /// safety; enforced by [`DaoStore::register`]. + pub name: String, + + /// Free-form description for humans. Optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Governor script address (bech32). Holds the singleton + /// GovernorDatum UTxO. + pub governor_addr: String, + + /// Stakes script address (bech32). Each user's stake is a UTxO at + /// this address with a StakeDatum. For Agora deployments fronted by + /// Clarity / MLabs, this address is shared across all DAOs (the + /// Plutarch validator is parameterized by the gov token, so one + /// script address handles all of them). + pub stakes_addr: String, + + /// Treasury script address (bech32). Holds DAO treasury funds. Spendable + /// only by tx that burns a single GAT minted by a passed proposal. + pub treasury_addr: String, + + /// Governance token policy id (56 hex chars / 28 bytes). + pub gov_token_policy: String, + + /// Governance token asset name in hex. + pub gov_token_name_hex: String, + + /// Initial-spend tx ref of the DAO bootstrap, as `txhash#index`. + /// Used by Agora as the "DAO identifier" — every Agora call cites + /// this ref to disambiguate which DAO instance the tx is for. + pub initial_spend: String, + + /// Maximum cosigners on a proposal — copied from Agora bootstrap. + pub max_cosigners: u32, + + /// Treasury reference-config currency symbol (56 hex chars). + /// Identifies a config-bearing UTxO referenced by Agora effects. + pub treasury_ref_config: String, + + /// Cardano network this DAO lives on. + #[serde(default)] + pub network: DaoNetwork, +} + +impl DaoConfig { + /// Validate name + hex-formatted fields. Called on every save. + pub fn validate(&self) -> DaoResult<()> { + if self.name.is_empty() { + return Err(DaoError::Config("name is empty".into())); + } + if !self + .name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') + { + return Err(DaoError::Config(format!( + "name {:?} must match [a-z0-9_-]+", + self.name + ))); + } + if self.gov_token_policy.len() != 56 || hex::decode(&self.gov_token_policy).is_err() { + return Err(DaoError::Config(format!( + "gov_token_policy {:?} is not 56 hex chars", + self.gov_token_policy + ))); + } + if hex::decode(&self.gov_token_name_hex).is_err() { + return Err(DaoError::Config(format!( + "gov_token_name_hex {:?} is not valid hex", + self.gov_token_name_hex + ))); + } + if self.treasury_ref_config.len() != 56 + || hex::decode(&self.treasury_ref_config).is_err() + { + return Err(DaoError::Config(format!( + "treasury_ref_config {:?} is not 56 hex chars", + self.treasury_ref_config + ))); + } + if !self.initial_spend.contains('#') { + return Err(DaoError::Config(format!( + "initial_spend {:?} must be in 'txhash#index' form", + self.initial_spend + ))); + } + // Address validation is delegated to Pallas at first use; we + // don't bech32-decode here to avoid coupling config validation + // to the address parser. + Ok(()) + } +} + +/// Marker for the active-DAO file content. +/// +/// `.active` holds plain UTF-8 text matching one registered DAO name. +/// Wrapper exists so [`DaoStore`] returns a typed handle rather than +/// a raw string at the API boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveDao(pub String); + +impl ActiveDao { + pub fn name(&self) -> &str { + &self.0 + } +} + +/// File-backed DAO config store rooted at `/daos/`. +/// +/// Cheap to construct; no I/O happens until a method is called. Re- +/// reading from disk on every call is intentional — we want +/// add/remove/switch operations to take effect without daemon +/// restart, and the volume of DAOs on a single user's machine is +/// always tiny (single digits). +pub struct DaoStore { + root: PathBuf, +} + +impl DaoStore { + /// Construct a store under `/daos/`. Does not create the + /// directory — that happens lazily on first write. + pub fn new(data_dir: &Path) -> Self { + Self { + root: data_dir.join("daos"), + } + } + + fn config_path(&self, name: &str) -> PathBuf { + self.root.join(format!("{name}.json")) + } + + fn active_path(&self) -> PathBuf { + self.root.join(".active") + } + + fn ensure_dir(&self) -> DaoResult<()> { + fs::create_dir_all(&self.root)?; + Ok(()) + } + + /// Save a config and (if no DAO is active yet) make it active. + pub fn register(&self, cfg: &DaoConfig) -> DaoResult<()> { + cfg.validate()?; + self.ensure_dir()?; + let path = self.config_path(&cfg.name); + let bytes = serde_json::to_vec_pretty(cfg)?; + fs::write(&path, bytes)?; + + // First-registered DAO becomes active automatically — saves + // a step for the typical single-DAO user. + if self.get_active().is_err() { + self.set_active(&cfg.name)?; + } + Ok(()) + } + + /// Load by name. Returns [`DaoError::Config`] if missing. + pub fn load(&self, name: &str) -> DaoResult { + let path = self.config_path(name); + let bytes = fs::read(&path).map_err(|_| { + DaoError::Config(format!("DAO {name:?} not registered (no {})", path.display())) + })?; + let cfg: DaoConfig = serde_json::from_slice(&bytes)?; + cfg.validate()?; + Ok(cfg) + } + + /// List all registered DAO names, sorted. + pub fn list(&self) -> DaoResult> { + if !self.root.exists() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + for entry in fs::read_dir(&self.root)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("json") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + out.push(stem.to_string()); + } + } + } + out.sort(); + Ok(out) + } + + /// Delete a config by name. If it was the active DAO, clears active. + pub fn remove(&self, name: &str) -> DaoResult<()> { + let path = self.config_path(name); + if !path.exists() { + return Err(DaoError::Config(format!("DAO {name:?} not registered"))); + } + fs::remove_file(&path)?; + if let Ok(active) = self.get_active() { + if active.name() == name { + let _ = fs::remove_file(self.active_path()); + } + } + Ok(()) + } + + /// Set the active DAO. Errors if `name` isn't registered. + pub fn set_active(&self, name: &str) -> DaoResult<()> { + if !self.config_path(name).exists() { + return Err(DaoError::Config(format!( + "cannot activate DAO {name:?}: not registered" + ))); + } + self.ensure_dir()?; + fs::write(self.active_path(), name)?; + Ok(()) + } + + /// Read the active DAO marker. Errors if no DAO is active. + pub fn get_active(&self) -> DaoResult { + let path = self.active_path(); + let bytes = fs::read(&path) + .map_err(|_| DaoError::Config("no active DAO selected".into()))?; + let name = String::from_utf8(bytes) + .map_err(|e| DaoError::Config(format!(".active is not valid UTF-8: {e}")))?; + let name = name.trim().to_string(); + if name.is_empty() { + return Err(DaoError::Config(".active is empty".into())); + } + Ok(ActiveDao(name)) + } + + /// Resolve the named DAO, or fall through to the active one if `name` + /// is `None`. The standard pattern at every DAO-tool entry point. + pub fn resolve(&self, name: Option<&str>) -> DaoResult { + match name { + Some(n) => self.load(n), + None => { + let active = self.get_active()?; + self.load(active.name()) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn cfg(name: &str) -> DaoConfig { + DaoConfig { + name: name.to_string(), + description: Some("test".into()), + governor_addr: "addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy".into(), + stakes_addr: "addr1w9gexmeunzsykesf42d4eqet5yvzeap6trjnflxqtkcf66g5740fw".into(), + treasury_addr: "addr1wx2xrpft9f97ggz4u5yrkev4u340fzfsrqama95kp8l2v6qll696y".into(), + gov_token_policy: "9c4bd4a90cdb73d9ff681215ecf7dea9fb183d916d30487d17098e05".into(), + gov_token_name_hex: "546572726170696e".into(), + initial_spend: + "5a7e33f6c399a09b74d607397a20b105e6e1462dd67c6569fa7549eafcfe8cc5#0" + .into(), + max_cosigners: 5, + treasury_ref_config: + "d9a1cdac8d196ad9303e0faedba992ff31f5bc2186740c362686cfad".into(), + network: DaoNetwork::Mainnet, + } + } + + #[test] + fn register_makes_first_dao_active() { + let dir = tempdir().unwrap(); + let store = DaoStore::new(dir.path()); + store.register(&cfg("sulkta")).unwrap(); + assert_eq!(store.get_active().unwrap().name(), "sulkta"); + } + + #[test] + fn second_register_does_not_change_active() { + let dir = tempdir().unwrap(); + let store = DaoStore::new(dir.path()); + store.register(&cfg("sulkta")).unwrap(); + store.register(&cfg("bobs_dao")).unwrap(); + assert_eq!(store.get_active().unwrap().name(), "sulkta"); + } + + #[test] + fn list_is_sorted() { + let dir = tempdir().unwrap(); + let store = DaoStore::new(dir.path()); + store.register(&cfg("zzz")).unwrap(); + store.register(&cfg("aaa")).unwrap(); + store.register(&cfg("mmm")).unwrap(); + assert_eq!(store.list().unwrap(), vec!["aaa", "mmm", "zzz"]); + } + + #[test] + fn set_active_rejects_unknown() { + let dir = tempdir().unwrap(); + let store = DaoStore::new(dir.path()); + assert!(store.set_active("nope").is_err()); + } + + #[test] + fn remove_clears_active_if_was_active() { + let dir = tempdir().unwrap(); + let store = DaoStore::new(dir.path()); + store.register(&cfg("sulkta")).unwrap(); + store.remove("sulkta").unwrap(); + assert!(store.get_active().is_err()); + } + + #[test] + fn resolve_falls_through_to_active() { + let dir = tempdir().unwrap(); + let store = DaoStore::new(dir.path()); + store.register(&cfg("sulkta")).unwrap(); + let cfg = store.resolve(None).unwrap(); + assert_eq!(cfg.name, "sulkta"); + } + + #[test] + fn resolve_named_overrides_active() { + let dir = tempdir().unwrap(); + let store = DaoStore::new(dir.path()); + store.register(&cfg("sulkta")).unwrap(); + store.register(&cfg("bobs_dao")).unwrap(); + let cfg = store.resolve(Some("bobs_dao")).unwrap(); + assert_eq!(cfg.name, "bobs_dao"); + } + + #[test] + fn validate_rejects_bad_name() { + let mut c = cfg("sulkta"); + c.name = "Sulkta DAO".into(); // uppercase + space + assert!(c.validate().is_err()); + } + + #[test] + fn validate_rejects_short_policy() { + let mut c = cfg("sulkta"); + c.gov_token_policy = "abc".into(); + assert!(c.validate().is_err()); + } +} diff --git a/crates/aldabra-dao/src/error.rs b/crates/aldabra-dao/src/error.rs new file mode 100644 index 0000000..b26a24f --- /dev/null +++ b/crates/aldabra-dao/src/error.rs @@ -0,0 +1,52 @@ +//! Crate-wide error type. + +use thiserror::Error; + +/// Anything that can go wrong inside `aldabra-dao`. +/// +/// Boundary rule: `DaoError` does NOT wrap `aldabra_core::WalletError` or +/// `aldabra_chain::ChainError` directly — those sit at different layers. +/// Convert to a [`DaoError::Backend`] string via the `?`-helper at call sites +/// to keep the public error surface flat for MCP consumers. +#[derive(Debug, Error)] +pub enum DaoError { + /// Per-DAO config file is missing, malformed, or names a DAO we don't have. + #[error("dao config: {0}")] + Config(String), + + /// Bech32 / address parse failure. + #[error("address: {0}")] + Address(String), + + /// PlutusData encode/decode failure — the on-chain datum didn't match + /// the shape we expected. + #[error("datum: {0}")] + Datum(String), + + /// CBOR encode/decode failure (lower-level than [`Self::Datum`]). + #[error("cbor: {0}")] + Cbor(String), + + /// Backend (Koios / submit) returned an error. + #[error("backend: {0}")] + Backend(String), + + /// We couldn't find a required reference-script UTxO on chain. + #[error("reference script not found: {0}")] + RefScript(String), + + /// Caller asked for something the DAO can't provide right now (no active + /// proposal, no stake registered, locked stake, etc). + #[error("invalid state: {0}")] + State(String), + + /// I/O error against the DAO config dir or similar. + #[error("io: {0}")] + Io(#[from] std::io::Error), + + /// JSON serialization failure for [`crate::config::DaoConfig`]. + #[error("json: {0}")] + Json(#[from] serde_json::Error), +} + +pub type DaoResult = Result; diff --git a/crates/aldabra-dao/src/lib.rs b/crates/aldabra-dao/src/lib.rs new file mode 100644 index 0000000..8165ab7 --- /dev/null +++ b/crates/aldabra-dao/src/lib.rs @@ -0,0 +1,35 @@ +//! aldabra-dao — Agora-on-Cardano DAO interaction. +//! +//! Native client for any [Agora](https://github.com/Liqwid-Labs/agora) +//! deployment on Cardano. Multi-DAO from day one — Sulkta DAO, Bob's +//! DAO, and Alice's DAO are all first-class, configured per-instance +//! at `$ALDABRA_DATA/daos/.json`. +//! +//! ## Surface +//! +//! - [`config`] — per-DAO config files + active-DAO selector. +//! - [`agora`] — Plutarch datum/redeemer type ports with PlutusData +//! encode/decode. +//! - [`reader`] — Koios-backed state queries (governor, stakes, +//! proposals). +//! - [`builder`] — Plutus tx assembly per operation (stake_create, +//! proposal_vote, etc). +//! - [`error::DaoError`] — crate-internal error type. +//! +//! ## What this crate is NOT +//! +//! - Not a key store. Signing is delegated to `aldabra-core`. +//! - Not an MCP server. The `dao_*` tools that wrap these primitives +//! live in `aldabra-mcp`. +//! - Not Sulkta-specific. Every Sulkta value (TRP policy, governor +//! address, etc) comes from a [`config::DaoConfig`] loaded at +//! runtime, never compile-time. + +pub mod agora; +pub mod builder; +pub mod config; +pub mod error; +pub mod reader; + +pub use config::{ActiveDao, DaoConfig, DaoStore}; +pub use error::DaoError; diff --git a/crates/aldabra-dao/src/reader.rs b/crates/aldabra-dao/src/reader.rs new file mode 100644 index 0000000..734d984 --- /dev/null +++ b/crates/aldabra-dao/src/reader.rs @@ -0,0 +1,261 @@ +//! 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 (derived from +/// the gov-token policy). +#[derive(Debug, Clone)] +pub struct ProposalUtxo { + pub utxo_ref: 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>; + + /// Return all proposals for this DAO. + async fn list_proposals(&self, cfg: &DaoConfig) -> DaoResult>; +} + +// ---------------- 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) -> Self { + Self { + base_url: base_url.into(), + http: reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("reqwest client"), + } + } + + async fn address_info(&self, address: &str) -> DaoResult> { + 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::>() + .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> { + 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>::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> { + // 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(), + )) + } +} + +// ---------- 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, +} + +#[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>, + #[serde(default)] + inline_datum: Option, +} + +#[derive(Debug, Deserialize)] +struct KoiosAsset { + policy_id: String, + #[allow(dead_code)] + asset_name: Option, + quantity: String, +} + +#[derive(Debug, Deserialize)] +struct KoiosInlineDatum { + /// CBOR-hex encoded PlutusData. + bytes: String, + #[allow(dead_code)] + #[serde(default)] + value: Option, +} + +/// 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 { + let bytes = + hex::decode(hex_str).map_err(|e| DaoError::Cbor(format!("hex decode: {e}")))?; + pallas_codec::minicbor::decode::(&bytes) + .map_err(|e| DaoError::Cbor(format!("plutus data decode: {e}"))) +}