feat(dao): scaffold aldabra-dao crate (Phase 1 reads)

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/<name>.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.
This commit is contained in:
Kayos 2026-05-05 13:40:12 -07:00
parent 66829c9aea
commit 41195ece4f
15 changed files with 2018 additions and 3 deletions

View file

@ -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",
]

View file

@ -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/<name>.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 }

View file

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

View file

@ -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<PlutusData> {
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<Self> {
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);
}
}
}

View file

@ -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,
};

View file

@ -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>) -> 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<PlutusData> {
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<PlutusData>)> {
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<i128> {
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<u8>.
pub fn as_bytes(pd: &PlutusData) -> DaoResult<Vec<u8>> {
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<PlutusData>> {
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<Vec<(&PlutusData, &PlutusData)>> {
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);
}
}

View file

@ -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<Self> {
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<PlutusData> {
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<Self> {
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<PlutusData> {
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<Self> {
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<PlutusData> {
let pairs = self
.0
.iter()
.map(|(k, v)| Ok((int(*k as i128)?, int(*v as i128)?)))
.collect::<DaoResult<Vec<_>>>()?;
Ok(PlutusData::Map(KeyValuePairs::from(pairs)))
}
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
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::<DaoResult<Vec<_>>>()?;
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<Credential>,
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<PlutusData> {
let cosigners_pd: Vec<PlutusData> = 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<Self> {
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::<DaoResult<Vec<_>>>()?,
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<PlutusData> {
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);
}
}

View file

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

View file

@ -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<u8>),
Script(Vec<u8>),
}
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<Self> {
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<PlutusData> {
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<Self> {
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<PlutusData> {
Ok(constr(
0,
vec![int(self.proposal_id as i128)?, self.action.to_plutus_data()?],
))
}
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
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<Credential>,
/// Active vote / cosign / create locks. Must be empty to deposit/withdraw.
pub locked_by: Vec<ProposalLock>,
}
impl StakeDatum {
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
let delegated_pd = match &self.delegated_to {
Some(c) => constr(0, vec![c.to_plutus_data()]),
None => constr(1, vec![]),
};
let locks_pd: Vec<PlutusData> = self
.locked_by
.iter()
.map(|l| l.to_plutus_data())
.collect::<DaoResult<Vec<_>>>()?;
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<Self> {
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<Credential> 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::<DaoResult<Vec<_>>>()?;
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<PlutusData> {
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<u8> {
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);
}
}
}

View file

@ -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<StagingTransaction>

View file

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

View file

@ -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
/// (`<name>.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<String>,
/// 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 `<data_dir>/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 `<data_dir>/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<DaoConfig> {
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<Vec<String>> {
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<ActiveDao> {
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<DaoConfig> {
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());
}
}

View file

@ -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<T> = Result<T, DaoError>;

View file

@ -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/<name>.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;

View file

@ -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<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 {
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<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>> {
// 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<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,
#[allow(dead_code)]
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}")))
}