From 09e8bb3e1df79a4ea866b8d95fd0dc7548da3726 Mon Sep 17 00:00:00 2001 From: Kayos Date: Wed, 6 May 2026 10:13:11 -0700 Subject: [PATCH] =?UTF-8?q?feat(dao):=20Phase=204c-bis-1=20+=204c-bis-2=20?= =?UTF-8?q?=E2=80=94=20typed=20EffectsMap=20+=20GAT=20policy=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Type port effects_raw → EffectsMap (4c-bis-1) Replace ProposalDatum.effects_raw: PlutusData with typed effects: EffectsMap. Required precondition for the upcoming proposal_mint_gats builder — every downstream piece reads typed effects, not opaque bytes. - ProposalEffectMetadata { datum_hash: 32 bytes, script_hash: Option<28 bytes> } ProductIsData → CBOR Array. Field order matches Agora.Proposal.hs:296. Maybe ScriptHash → Constr 0 [bytes]/Constr 1 []. - EffectsMap: Vec<(ResultTag i64, Vec<(ScriptHash, ProposalEffectMetadata)>)>. Encoded as nested PlutusData::Map. Keys preserved in insertion order (Plutus map equality is set-like, but validator builds the expected datum in same order so we stay consistent). - EffectsMap::info_only(&[0, 1]) helper for the proposal_create case (every result_tag → empty inner map). Replaces the hand-rolled PlutusData::Map. - has_neutral_effect() + keys() helpers for validator preflight checks. Live-decode test (decodes_sulkta_live_proposal_zero) tightened: Sulkta #0 is NOT pure InfoOnly — tag 1 has a real effect targeting script hash 92b7..96f with datum_hash 046dff..e83c (no auth-script wrapper). Tag 0 is empty so phasNeutralEffect still passes. Test asserts the full typed shape now + round-trips via decode↔encode. All 4 builders' fixtures updated: effects_raw: constr(0, vec![]) → effects: EffectsMap::info_only(&[0, 1]). Unused constr/PlutusData/ KeyValuePairs imports pruned. ## DaoConfig GAT policy fields (4c-bis-2) - DaoConfig.gat_policy: Option (56 hex) - ScriptRefs.gat_policy_ref: Option (txhash#index) - DaoConfig::validate now checks gat_policy + stake_st_policy + proposal_st_policy are 56 hex chars when set - All DaoConfig fixtures updated with gat_policy: None - DaoRegisterArgs gains gat_policy + gat_policy_ref optional fields - dao_show output includes the new fields automatically (serde) Sulkta-specific note: gat_policy hash isn't observable on chain yet (no MintGATs tx has fired). Hand-populate from MLabs deployment record when ready, or compute from the deployed governor's CBOR parameters. --- crates/aldabra-dao/src/agora/proposal.rs | 238 ++++++++++++++++-- .../src/builder/proposal_advance.rs | 7 +- .../src/builder/proposal_cosign.rs | 7 +- .../src/builder/proposal_create.rs | 17 +- .../aldabra-dao/src/builder/proposal_vote.rs | 7 +- .../aldabra-dao/src/builder/stake_destroy.rs | 1 + crates/aldabra-dao/src/config.rs | 34 +++ crates/aldabra-dao/src/discovery.rs | 3 +- crates/aldabra-mcp/src/tools.rs | 12 + 9 files changed, 285 insertions(+), 41 deletions(-) diff --git a/crates/aldabra-dao/src/agora/proposal.rs b/crates/aldabra-dao/src/agora/proposal.rs index 3ec3407..4befc29 100644 --- a/crates/aldabra-dao/src/agora/proposal.rs +++ b/crates/aldabra-dao/src/agora/proposal.rs @@ -2,22 +2,22 @@ //! //! Mirrors [`Agora.Proposal`](https://github.com/Liqwid-Labs/agora/blob/master/agora/Agora/Proposal.hs). //! -//! ## Effects map (deferred) +//! ## Effects map (typed as of Phase 4c-bis-1, 2026-05-06) //! -//! `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`. +//! `ProposalDatum.effects` is a `Map ResultTag (Map ScriptHash ProposalEffectMetadata)` +//! exposed as the typed [`EffectsMap`] struct. Each `ProposalEffectMetadata` +//! carries `{ datum_hash, script_hash: Option<_> }` — when an effected +//! proposal advances Locked → Finished, the governor's MintGATs path +//! mints one GAT per (script_hash, metadata) pair to the +//! `script_hash`'s validator address with `datum_hash` as the output +//! datum hash. See `memory/spec-gat-minting-phase4c-bis.md` for the +//! full path. use pallas_codec::utils::{KeyValuePairs, MaybeIndefArray}; use pallas_primitives::PlutusData; use crate::agora::plutus_data::{ - as_array, as_int, as_map, as_product, constr, int, product, + as_array, as_bytes, as_constr, as_int, as_map, as_product, bytes, constr, int, product, }; use crate::agora::stake::Credential; use crate::error::{DaoError, DaoResult}; @@ -171,17 +171,184 @@ impl ProposalVotes { } } +/// `ProposalEffectMetadata` — ProductIsData → CBOR Array `[datum_hash, maybe_script_hash]`. +/// +/// Field order matches `Agora.Proposal.ProposalEffectMetadata`: +/// 1. `datum_hash :: DatumHash` (32 bytes) — hash of the datum sent to +/// the effect validator together with the GAT. +/// 2. `script_hash :: Maybe ScriptHash` (28 bytes) — when `Some`, this +/// becomes the GAT's asset_name (auth-check pattern). When `None`, +/// GAT asset_name is the empty bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposalEffectMetadata { + pub datum_hash: Vec, + pub script_hash: Option>, +} + +impl ProposalEffectMetadata { + pub fn to_plutus_data(&self) -> DaoResult { + if self.datum_hash.len() != 32 { + return Err(DaoError::Datum(format!( + "ProposalEffectMetadata.datum_hash must be 32 bytes, got {}", + self.datum_hash.len() + ))); + } + if let Some(ref h) = self.script_hash { + if h.len() != 28 { + return Err(DaoError::Datum(format!( + "ProposalEffectMetadata.script_hash must be 28 bytes, got {}", + h.len() + ))); + } + } + let maybe_sh = match &self.script_hash { + Some(h) => constr(0, vec![bytes(h)]), + None => constr(1, vec![]), + }; + Ok(product(vec![bytes(&self.datum_hash), maybe_sh])) + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let fields = as_product(pd)?; + if fields.len() != 2 { + return Err(DaoError::Datum(format!( + "ProposalEffectMetadata expects Array [datum_hash, maybe_script_hash], got {} fields", + fields.len() + ))); + } + let datum_hash = as_bytes(&fields[0])?; + if datum_hash.len() != 32 { + return Err(DaoError::Datum(format!( + "ProposalEffectMetadata.datum_hash must be 32 bytes, got {}", + datum_hash.len() + ))); + } + let (idx, inner) = as_constr(&fields[1])?; + let script_hash = match (idx, inner.len()) { + (0, 1) => { + let h = as_bytes(&inner[0])?; + if h.len() != 28 { + return Err(DaoError::Datum(format!( + "ProposalEffectMetadata.script_hash must be 28 bytes, got {}", + h.len() + ))); + } + Some(h) + } + (1, 0) => None, + _ => { + return Err(DaoError::Datum(format!( + "Maybe expects Constr 0[1] | 1[0], got Constr {idx} with {} fields", + inner.len() + ))); + } + }; + Ok(ProposalEffectMetadata { + datum_hash, + script_hash, + }) + } +} + +/// `EffectsMap` — `Map ResultTag (Map ScriptHash ProposalEffectMetadata)`. +/// +/// Encoded as a nested Plutus Map. Outer keys are ResultTag (Integer), +/// inner keys are 28-byte script hashes, inner values are +/// `ProposalEffectMetadata`. Keys preserved in insertion order — Plutus +/// uses `==` on Data which doesn't care about map ordering for set +/// equality, but the validator's `mkRecordConstr expectedDatum` builds +/// the map in the same order we got it, so preserving order is the +/// safe default. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct EffectsMap(pub Vec<(i64, Vec<(Vec, ProposalEffectMetadata)>)>); + +impl EffectsMap { + /// Build an InfoOnly map: every result_tag maps to an empty inner map. + /// Use for proposals that never trigger effects regardless of outcome. + pub fn info_only(result_tags: &[i64]) -> Self { + EffectsMap(result_tags.iter().map(|t| (*t, Vec::new())).collect()) + } + + pub fn to_plutus_data(&self) -> DaoResult { + let outer = self + .0 + .iter() + .map(|(tag, inner)| { + let inner_pairs = inner + .iter() + .map(|(sh, meta)| { + if sh.len() != 28 { + return Err(DaoError::Datum(format!( + "EffectsMap inner script hash must be 28 bytes, got {}", + sh.len() + ))); + } + Ok((bytes(sh), meta.to_plutus_data()?)) + }) + .collect::>>()?; + let inner_pd = PlutusData::Map(KeyValuePairs::from(inner_pairs)); + Ok((int(*tag as i128)?, inner_pd)) + }) + .collect::>>()?; + Ok(PlutusData::Map(KeyValuePairs::from(outer))) + } + + pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { + let outer_entries = as_map(pd)?; + let mut out: Vec<(i64, Vec<(Vec, ProposalEffectMetadata)>)> = Vec::new(); + for (k, v) in outer_entries { + let tag = as_int(k)? as i64; + let inner_entries = as_map(v)?; + let mut inner: Vec<(Vec, ProposalEffectMetadata)> = Vec::new(); + for (ik, iv) in inner_entries { + let sh = as_bytes(ik)?; + if sh.len() != 28 { + return Err(DaoError::Datum(format!( + "EffectsMap inner script hash must be 28 bytes, got {}", + sh.len() + ))); + } + let meta = ProposalEffectMetadata::from_plutus_data(iv)?; + inner.push((sh, meta)); + } + out.push((tag, inner)); + } + Ok(EffectsMap(out)) + } + + /// Returns the inner map for `result_tag`, or empty if absent. + pub fn for_tag(&self, tag: i64) -> &[(Vec, ProposalEffectMetadata)] { + for (t, inner) in &self.0 { + if *t == tag { + return inner; + } + } + &[] + } + + /// True iff at least one result_tag has an empty inner map. Mirrors + /// the validator's `phasNeutralEffect` check on `proposal.effects`. + pub fn has_neutral_effect(&self) -> bool { + self.0.iter().any(|(_, inner)| inner.is_empty()) + } + + /// Set of result_tag keys, preserving insertion order. Used for + /// `pisEffectsVotesCompatible` (effects keys == votes keys). + pub fn keys(&self) -> Vec { + self.0.iter().map(|(t, _)| *t).collect() + } +} + /// `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)] +/// AUDIT-2026-05-06 / Phase 4c-bis-1: `effects_raw: PlutusData` replaced +/// with typed `effects: EffectsMap`. The opaque-bytes shape from Phase 1 +/// is gone — every code path now sees the structure. +#[derive(Debug, Clone, PartialEq, Eq)] 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 effects: EffectsMap, pub status: ProposalStatus, pub cosigners: Vec, pub thresholds: ProposalThresholds, @@ -200,7 +367,7 @@ impl ProposalDatum { .collect(); Ok(product(vec![ int(self.proposal_id as i128)?, - self.effects_raw.clone(), + self.effects.to_plutus_data()?, self.status.to_plutus_data()?, PlutusData::Array(MaybeIndefArray::Indef(cosigners_pd)), self.thresholds.to_plutus_data()?, @@ -220,7 +387,7 @@ impl ProposalDatum { } Ok(ProposalDatum { proposal_id: as_int(&fields[0])? as i64, - effects_raw: fields[1].clone(), + effects: EffectsMap::from_plutus_data(&fields[1])?, status: ProposalStatus::from_plutus_data(&fields[2])?, cosigners: as_array(&fields[3])? .iter() @@ -362,14 +529,45 @@ mod tests { assert_eq!(prop.timing_config.voting_time, 7 * 86_400 * 1000); // Decoded from CBOR `1b 0000019c7d5c4d17` = 1771629726999 ms = 2026-04-21 15:42:06 UTC assert_eq!(prop.starting_time, 1_771_629_726_999); + + // Phase 4c-bis-1 typed-effects assertion. Sulkta #0 is NOT pure + // InfoOnly (despite earlier session notes calling it that): tag 0 + // has empty effects but tag 1 has a real effect targeting script + // hash 92b7..96f with datum hash 046dff..e83c, no auth-script + // wrapper. The proposal still has phasNeutralEffect because tag 0 + // is empty. This anchors the typed decoder against the actual + // effected-proposal shape. + assert_eq!(prop.effects.keys(), vec![0i64, 1]); + assert!(prop.effects.has_neutral_effect(), "tag 0 must be empty"); + assert!(prop.effects.for_tag(0).is_empty()); + let tag1 = prop.effects.for_tag(1); + assert_eq!(tag1.len(), 1, "tag 1 has exactly one effect"); + let (effect_script_hash, effect_meta) = &tag1[0]; + assert_eq!( + hex::encode(effect_script_hash), + "92b7725bb0c7c06083af729d38f5589c2f85f16c83fe48860399e96f" + ); + assert_eq!( + hex::encode(&effect_meta.datum_hash), + "046dff6c96199a55715c65b9ae62c3be1b6600038cc3116eeb20886a7d66e83c" + ); + assert!(effect_meta.script_hash.is_none(), "no auth-script wrapper"); + + // Round-trip via PlutusData. Same caveat as the live-stake tests: + // can't byte-exact (def-vs-indef array drift between pallas-codec + // and chain), so check decode(reencode(decode(cbor))) == decode. + let re_encoded = pallas_codec::minicbor::to_vec(&prop.to_plutus_data().unwrap()).unwrap(); + let re_pd: PlutusData = pallas_codec::minicbor::decode(&re_encoded).unwrap(); + let round_tripped = + ProposalDatum::from_plutus_data(&re_pd).expect("re-decode proposal #0"); + assert_eq!(round_tripped, prop, "round-trip lost a field"); } #[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, + effects: EffectsMap::info_only(&[0, 1]), status: ProposalStatus::Draft, cosigners: vec![Credential::PubKey(vec![0u8; 28])], thresholds: ProposalThresholds { diff --git a/crates/aldabra-dao/src/builder/proposal_advance.rs b/crates/aldabra-dao/src/builder/proposal_advance.rs index 50b865c..a8374dc 100644 --- a/crates/aldabra-dao/src/builder/proposal_advance.rs +++ b/crates/aldabra-dao/src/builder/proposal_advance.rs @@ -305,7 +305,7 @@ pub fn build_unsigned_proposal_advance( let new_proposal = ProposalDatum { proposal_id, - effects_raw: args.proposal.datum.effects_raw.clone(), + effects: args.proposal.datum.effects.clone(), status: to_status, cosigners: args.proposal.datum.cosigners.clone(), thresholds: ProposalThresholds { @@ -462,7 +462,7 @@ pub fn build_unsigned_proposal_advance( #[cfg(test)] mod tests { use super::*; - use crate::agora::plutus_data::constr; + // constr no longer used in fixtures (effects: EffectsMap::info_only(...)) use crate::config::ScriptRefs; fn pkh_a() -> Vec { vec![0x10; 28] } @@ -474,7 +474,7 @@ mod tests { fn sample_proposal_datum() -> ProposalDatum { ProposalDatum { proposal_id: 1, - effects_raw: constr(0, vec![]), + effects: crate::agora::proposal::EffectsMap::info_only(&[0, 1]), status: ProposalStatus::Draft, cosigners: vec![ Credential::PubKey(pkh_a()), @@ -530,6 +530,7 @@ mod tests { proposal_st_policy: Some( "9c78c11e4e4f49d4874d4ecb7d42675f545251d0affba5d7162097fd".into(), ), + gat_policy: None, script_refs: ScriptRefs::default(), }, proposal: ProposalUtxoIn { diff --git a/crates/aldabra-dao/src/builder/proposal_cosign.rs b/crates/aldabra-dao/src/builder/proposal_cosign.rs index c2e6c16..e1c25bf 100644 --- a/crates/aldabra-dao/src/builder/proposal_cosign.rs +++ b/crates/aldabra-dao/src/builder/proposal_cosign.rs @@ -250,7 +250,7 @@ pub fn build_unsigned_proposal_cosign( // Proposal: cosigners updated, all else preserved bit-exact. let new_proposal = ProposalDatum { proposal_id, - effects_raw: args.proposal.datum.effects_raw.clone(), + effects: args.proposal.datum.effects.clone(), status: args.proposal.datum.status, cosigners: new_cosigners.clone(), thresholds: ProposalThresholds { ..args.proposal.datum.thresholds.clone() }, @@ -443,7 +443,7 @@ pub fn build_unsigned_proposal_cosign( #[cfg(test)] mod tests { use super::*; - use crate::agora::plutus_data::constr; + // constr no longer used in fixtures (effects: EffectsMap::info_only(...)) use crate::agora::proposal::{ProposalThresholds, ProposalTimingConfig}; use crate::config::ScriptRefs; @@ -457,7 +457,7 @@ mod tests { fn sample_proposal_datum() -> ProposalDatum { ProposalDatum { proposal_id: 1, - effects_raw: constr(0, vec![]), + effects: crate::agora::proposal::EffectsMap::info_only(&[0, 1]), status: ProposalStatus::Draft, cosigners: vec![ Credential::PubKey(other_pkh_a()), @@ -505,6 +505,7 @@ mod tests { proposal_st_policy: Some( "9c78c11e4e4f49d4874d4ecb7d42675f545251d0affba5d7162097fd".into(), ), + gat_policy: None, script_refs: ScriptRefs::default(), }, stake_in: StakeUtxoIn { diff --git a/crates/aldabra-dao/src/builder/proposal_create.rs b/crates/aldabra-dao/src/builder/proposal_create.rs index 18ccc62..2e388b3 100644 --- a/crates/aldabra-dao/src/builder/proposal_create.rs +++ b/crates/aldabra-dao/src/builder/proposal_create.rs @@ -40,9 +40,7 @@ use pallas_addresses::Address; use pallas_codec::minicbor; -use pallas_codec::utils::KeyValuePairs; use pallas_crypto::hash::Hash; -use pallas_primitives::PlutusData; use pallas_txbuilder::{BuildConway, ExUnits, Input, Output, StagingTransaction}; use crate::agora::governor::GovernorDatum; @@ -324,18 +322,14 @@ pub fn build_unsigned_proposal_create( // `pisEffectsVotesCompatible` (effects keys == votes keys). // // For InfoOnly: both ResultTag(0) and ResultTag(1) map to empty inner - // maps (no effect scripts trigger regardless of vote outcome). - let empty_inner: PlutusData = PlutusData::Map(KeyValuePairs::from( - Vec::<(PlutusData, PlutusData)>::new(), - )); - let effects_pd = PlutusData::Map(KeyValuePairs::from(vec![ - (crate::agora::plutus_data::int(0)?, empty_inner.clone()), - (crate::agora::plutus_data::int(1)?, empty_inner), - ])); + // maps (no effect scripts trigger regardless of vote outcome). Phase + // 4c-bis-1 typed-port: use EffectsMap::info_only instead of hand-rolling + // the PlutusData::Map. + let effects = crate::agora::proposal::EffectsMap::info_only(&[0, 1]); let new_proposal = ProposalDatum { proposal_id: new_proposal_id, - effects_raw: effects_pd, + effects, status: ProposalStatus::Draft, cosigners: vec![proposer_cred.clone()], thresholds: ProposalThresholds { ..args.governor.datum.proposal_thresholds.clone() }, @@ -702,6 +696,7 @@ mod tests { proposal_st_policy: Some( "9c78c11e4e4f49d4874d4ecb7d42675f545251d0affba5d7162097fd".into(), ), + gat_policy: None, script_refs: Default::default(), }, governor: GovernorUtxoIn { diff --git a/crates/aldabra-dao/src/builder/proposal_vote.rs b/crates/aldabra-dao/src/builder/proposal_vote.rs index dffd09e..209e33c 100644 --- a/crates/aldabra-dao/src/builder/proposal_vote.rs +++ b/crates/aldabra-dao/src/builder/proposal_vote.rs @@ -315,7 +315,7 @@ pub fn build_unsigned_proposal_vote( let new_proposal = ProposalDatum { proposal_id, - effects_raw: args.proposal.datum.effects_raw.clone(), + effects: args.proposal.datum.effects.clone(), status: args.proposal.datum.status, cosigners: args.proposal.datum.cosigners.clone(), thresholds: args.proposal.datum.thresholds.clone(), @@ -532,7 +532,7 @@ pub fn build_unsigned_proposal_vote( #[cfg(test)] mod tests { use super::*; - use crate::agora::plutus_data::constr; + // constr no longer used in fixtures (effects: EffectsMap::info_only(...)) use crate::agora::proposal::{ProposalThresholds, ProposalTimingConfig}; use crate::config::ScriptRefs; @@ -543,7 +543,7 @@ mod tests { fn sample_proposal_datum() -> ProposalDatum { ProposalDatum { proposal_id: 1, - effects_raw: constr(0, vec![]), + effects: crate::agora::proposal::EffectsMap::info_only(&[0, 1]), status: ProposalStatus::VotingReady, cosigners: vec![Credential::PubKey(voter_pkh_bytes())], thresholds: ProposalThresholds { @@ -594,6 +594,7 @@ mod tests { proposal_st_policy: Some( "9c78c11e4e4f49d4874d4ecb7d42675f545251d0affba5d7162097fd".into(), ), + gat_policy: None, script_refs: ScriptRefs::default(), }, stake_in: StakeUtxoIn { diff --git a/crates/aldabra-dao/src/builder/stake_destroy.rs b/crates/aldabra-dao/src/builder/stake_destroy.rs index e50d669..749000d 100644 --- a/crates/aldabra-dao/src/builder/stake_destroy.rs +++ b/crates/aldabra-dao/src/builder/stake_destroy.rs @@ -298,6 +298,7 @@ mod tests { "732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(), ), proposal_st_policy: None, + gat_policy: None, script_refs: ScriptRefs::default(), }, stake_in: StakeUtxoIn { diff --git a/crates/aldabra-dao/src/config.rs b/crates/aldabra-dao/src/config.rs index cd3c078..ff4f3bb 100644 --- a/crates/aldabra-dao/src/config.rs +++ b/crates/aldabra-dao/src/config.rs @@ -131,6 +131,20 @@ pub struct DaoConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub proposal_st_policy: Option, + /// Governance Authority Token (GAT) minting policy id (56 hex chars). + /// Phase 4c-bis: required for the MintGATs path that fires when an + /// effected proposal advances Locked → Finished. Parameterized by the + /// governor STT asset class — the policy is deterministic given the + /// DAO's deployment params. + /// + /// **Caveat for Sulkta:** as of 2026-05-06 no MintGATs tx has fired, + /// so the policy id isn't observable from any minted asset on chain. + /// Either decode the deployed governor validator's CBOR to extract + /// the parameter, or hand-populate from MLabs's deployment record. + /// The GAT-minting builder errors cleanly if this is `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gat_policy: Option, + /// Reference UTxOs for each Agora script (so we don't re-discover on /// every tx). Stored as `txhash#index` strings. Optional — falls back /// to a lookup at use time when absent. @@ -155,6 +169,11 @@ pub struct ScriptRefs { pub stake_st_policy: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub proposal_st_policy: Option, + /// Reference UTxO for the Governance Authority Token (GAT) minting + /// policy script. Populate alongside `cfg.gat_policy` for the + /// Phase 4c-bis MintGATs path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gat_policy_ref: Option, } impl DaoConfig { @@ -199,6 +218,20 @@ impl DaoConfig { self.initial_spend ))); } + // Validate optional 56-hex policy ids when set. + for (name, val) in [ + ("stake_st_policy", &self.stake_st_policy), + ("proposal_st_policy", &self.proposal_st_policy), + ("gat_policy", &self.gat_policy), + ] { + if let Some(v) = val { + if v.len() != 56 || hex::decode(v).is_err() { + return Err(DaoError::Config(format!( + "{name} {v:?} is not 56 hex chars" + ))); + } + } + } // Address validation is delegated to Pallas at first use; we // don't bech32-decode here to avoid coupling config validation // to the address parser. @@ -377,6 +410,7 @@ mod tests { proposal_addr: None, stake_st_policy: None, proposal_st_policy: None, + gat_policy: None, script_refs: ScriptRefs::default(), } } diff --git a/crates/aldabra-dao/src/discovery.rs b/crates/aldabra-dao/src/discovery.rs index 2d3fafe..9af0188 100644 --- a/crates/aldabra-dao/src/discovery.rs +++ b/crates/aldabra-dao/src/discovery.rs @@ -391,7 +391,8 @@ mod tests { proposal_addr: None, stake_st_policy: None, proposal_st_policy: None, - script_refs: ScriptRefs::default(), + gat_policy: None, + script_refs: ScriptRefs::default(), } } diff --git a/crates/aldabra-mcp/src/tools.rs b/crates/aldabra-mcp/src/tools.rs index e5a470f..f9e23e3 100644 --- a/crates/aldabra-mcp/src/tools.rs +++ b/crates/aldabra-mcp/src/tools.rs @@ -1606,11 +1606,13 @@ impl WalletService { proposal_addr, stake_st_policy, proposal_st_policy, + gat_policy, governor_validator_ref, stake_validator_ref, proposal_validator_ref, stake_st_policy_ref, proposal_st_policy_ref, + gat_policy_ref, }: DaoRegisterArgs, ) -> Result { let cfg = DaoConfig { @@ -1632,6 +1634,7 @@ impl WalletService { proposal_addr, stake_st_policy, proposal_st_policy, + gat_policy, script_refs: ScriptRefs { governor_validator: governor_validator_ref, stake_validator: stake_validator_ref, @@ -1639,6 +1642,7 @@ impl WalletService { treasury_validator: None, stake_st_policy: stake_st_policy_ref, proposal_st_policy: proposal_st_policy_ref, + gat_policy_ref, }, }; self.inner @@ -2890,6 +2894,11 @@ pub struct DaoRegisterArgs { /// 56 hex chars — ProposalST minting policy id. #[serde(default)] pub proposal_st_policy: Option, + /// 56 hex chars — Governance Authority Token (GAT) minting policy id. + /// Required for the Phase 4c-bis MintGATs path. Hand-populate from + /// the DAO's deployment params. + #[serde(default)] + pub gat_policy: Option, /// `txhash#index` reference UTxO carrying the governor validator script. #[serde(default)] pub governor_validator_ref: Option, @@ -2905,6 +2914,9 @@ pub struct DaoRegisterArgs { /// `txhash#index` reference UTxO carrying the ProposalST minting policy script. #[serde(default)] pub proposal_st_policy_ref: Option, + /// `txhash#index` reference UTxO carrying the GAT minting policy script. + #[serde(default)] + pub gat_policy_ref: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)]