feat(dao): Phase 4c-bis-1 + 4c-bis-2 — typed EffectsMap + GAT policy config
## 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<String> (56 hex)
- ScriptRefs.gat_policy_ref: Option<String> (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.
This commit is contained in:
parent
7d440288bd
commit
09e8bb3e1d
9 changed files with 285 additions and 41 deletions
|
|
@ -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<u8>,
|
||||
pub script_hash: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ProposalEffectMetadata {
|
||||
pub fn to_plutus_data(&self) -> DaoResult<PlutusData> {
|
||||
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<Self> {
|
||||
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<ScriptHash> 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<u8>, 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<PlutusData> {
|
||||
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::<DaoResult<Vec<_>>>()?;
|
||||
let inner_pd = PlutusData::Map(KeyValuePairs::from(inner_pairs));
|
||||
Ok((int(*tag as i128)?, inner_pd))
|
||||
})
|
||||
.collect::<DaoResult<Vec<_>>>()?;
|
||||
Ok(PlutusData::Map(KeyValuePairs::from(outer)))
|
||||
}
|
||||
|
||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||
let outer_entries = as_map(pd)?;
|
||||
let mut out: Vec<(i64, Vec<(Vec<u8>, 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<u8>, 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<u8>, 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<i64> {
|
||||
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<Credential>,
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<u8> { 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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -298,6 +298,7 @@ mod tests {
|
|||
"732ff23ade752d46c903c16866b0cb3e2e977216db594bb47c434696".into(),
|
||||
),
|
||||
proposal_st_policy: None,
|
||||
gat_policy: None,
|
||||
script_refs: ScriptRefs::default(),
|
||||
},
|
||||
stake_in: StakeUtxoIn {
|
||||
|
|
|
|||
|
|
@ -131,6 +131,20 @@ pub struct DaoConfig {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub proposal_st_policy: Option<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub proposal_st_policy: Option<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String, String> {
|
||||
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<String>,
|
||||
/// 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<String>,
|
||||
/// `txhash#index` reference UTxO carrying the governor validator script.
|
||||
#[serde(default)]
|
||||
pub governor_validator_ref: Option<String>,
|
||||
|
|
@ -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<String>,
|
||||
/// `txhash#index` reference UTxO carrying the GAT minting policy script.
|
||||
#[serde(default)]
|
||||
pub gat_policy_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue