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:
parent
66829c9aea
commit
41195ece4f
15 changed files with 2018 additions and 3 deletions
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
79
crates/aldabra-dao/Cargo.toml
Normal file
79
crates/aldabra-dao/Cargo.toml
Normal 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 }
|
||||
|
||||
20
crates/aldabra-dao/src/agora/authority_token.rs
Normal file
20
crates/aldabra-dao/src/agora/authority_token.rs
Normal 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.
|
||||
115
crates/aldabra-dao/src/agora/governor.rs
Normal file
115
crates/aldabra-dao/src/agora/governor.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
crates/aldabra-dao/src/agora/mod.rs
Normal file
53
crates/aldabra-dao/src/agora/mod.rs
Normal 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,
|
||||
};
|
||||
204
crates/aldabra-dao/src/agora/plutus_data.rs
Normal file
204
crates/aldabra-dao/src/agora/plutus_data.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
362
crates/aldabra-dao/src/agora/proposal.rs
Normal file
362
crates/aldabra-dao/src/agora/proposal.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
37
crates/aldabra-dao/src/agora/reference_scripts.rs
Normal file
37
crates/aldabra-dao/src/agora/reference_scripts.rs
Normal 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.
|
||||
357
crates/aldabra-dao/src/agora/stake.rs
Normal file
357
crates/aldabra-dao/src/agora/stake.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
crates/aldabra-dao/src/agora/treasury.rs
Normal file
14
crates/aldabra-dao/src/agora/treasury.rs
Normal 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>
|
||||
17
crates/aldabra-dao/src/builder/mod.rs
Normal file
17
crates/aldabra-dao/src/builder/mod.rs
Normal 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.
|
||||
407
crates/aldabra-dao/src/config.rs
Normal file
407
crates/aldabra-dao/src/config.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
52
crates/aldabra-dao/src/error.rs
Normal file
52
crates/aldabra-dao/src/error.rs
Normal 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>;
|
||||
35
crates/aldabra-dao/src/lib.rs
Normal file
35
crates/aldabra-dao/src/lib.rs
Normal 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;
|
||||
261
crates/aldabra-dao/src/reader.rs
Normal file
261
crates/aldabra-dao/src/reader.rs
Normal 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}")))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue