From a8ecdfa45d694a9d7868accebc7cec6919a6e19b Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 5 May 2026 15:41:03 -0700 Subject: [PATCH] =?UTF-8?q?fix(dao):=20EnumIsData=20=E2=86=92=20plain=20In?= =?UTF-8?q?teger,=20not=20Constr=20i=20[]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against Sulkta's live Proposal #0 datum 2026-05-05: status field is bare BigInt(3), not Constr 3 []. Plutarch's EnumIsData derive emits Integer-as-index in this Agora version. Affected: - ProposalStatus.{to,from}_plutus_data - GovernorRedeemer.to_plutus_data (consistency; no on-chain governor-redeemer evidence yet, but same EnumIsData derive) ProposalDatum.to_plutus_data signature updated for the new fallible ProposalStatus encoding (now returns DaoResult). Added regression test `decodes_sulkta_live_proposal_zero` that decodes Proposal #0's actual on-chain datum hex and asserts: proposal_id=0, status=Finished, cosigners=[Cobb's pkh], thresholds=20/100/100/1/1, votes={0:0, 1:0} (zero votes ever cast), starting_time=1772666551575ms. Closes audit findings 1 + 2 from memory/audit-sulkta-agora-2026-05-05.md. --- crates/aldabra-dao/src/agora/governor.rs | 31 +++++---- crates/aldabra-dao/src/agora/proposal.rs | 80 +++++++++++++++++------- 2 files changed, 76 insertions(+), 35 deletions(-) diff --git a/crates/aldabra-dao/src/agora/governor.rs b/crates/aldabra-dao/src/agora/governor.rs index ea3a427..0f72249 100644 --- a/crates/aldabra-dao/src/agora/governor.rs +++ b/crates/aldabra-dao/src/agora/governor.rs @@ -56,16 +56,23 @@ impl GovernorDatum { /// `GovernorRedeemer` — EnumIsData (declaration order): /// 0=CreateProposal, 1=MintGATs, 2=MutateGovernor. +/// +/// **Encoded as plain Integer**, not `Constr i []` — same Plutarch +/// `EnumIsData` pattern as `ProposalStatus` (verified against Sulkta's +/// on-chain Proposal #0 status field). No on-chain governor-redeemer +/// has been used yet to corroborate, but consistency with ProposalStatus +/// is overwhelming evidence. If Phase 4a's first dao_proposal_create +/// surfaces a mismatch, swap back to constr(self as u64, vec![]). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GovernorRedeemer { - CreateProposal, - MintGATs, - MutateGovernor, + CreateProposal = 0, + MintGATs = 1, + MutateGovernor = 2, } impl GovernorRedeemer { - pub fn to_plutus_data(self) -> PlutusData { - constr(self as u64, vec![]) + pub fn to_plutus_data(self) -> DaoResult { + int(self as i128) } } @@ -100,16 +107,16 @@ mod tests { } #[test] - fn governor_redeemer_indices() { - use crate::agora::plutus_data::as_constr; - for (r, i) in [ - (GovernorRedeemer::CreateProposal, 0u64), + fn governor_redeemer_encodes_as_integer() { + // Per Plutarch EnumIsData (matches ProposalStatus shape verified + // on chain). Plain Integer, not Constr i []. + for (r, expected) in [ + (GovernorRedeemer::CreateProposal, 0i128), (GovernorRedeemer::MintGATs, 1), (GovernorRedeemer::MutateGovernor, 2), ] { - let pd = r.to_plutus_data(); - let (idx, _) = as_constr(&pd).unwrap(); - assert_eq!(idx, i); + let pd = r.to_plutus_data().unwrap(); + assert_eq!(as_int(&pd).unwrap(), expected, "{:?}", r); } } diff --git a/crates/aldabra-dao/src/agora/proposal.rs b/crates/aldabra-dao/src/agora/proposal.rs index 1962d42..9bc4c05 100644 --- a/crates/aldabra-dao/src/agora/proposal.rs +++ b/crates/aldabra-dao/src/agora/proposal.rs @@ -23,36 +23,36 @@ use crate::agora::stake::Credential; use crate::error::{DaoError, DaoResult}; /// `data ProposalStatus = Draft | VotingReady | Locked | Finished` -/// via `EnumIsData` → `Constr i []` for `i` ∈ `[0,3]`. +/// via `EnumIsData` → **plain `Integer`** (NOT `Constr i []`). +/// +/// **Encoding correction 2026-05-05:** initial Phase 0 spec assumed +/// `EnumIsData` produces `Constr i []`. Real on-chain proposal #0 has +/// status field encoded as bare `BigInt(3)` (CBOR `03`). Plutarch's +/// `EnumIsData` actually emits Integer-as-index in this Agora version. +/// Correction verified by audit-sulkta-agora-2026-05-05.md. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProposalStatus { - Draft, - VotingReady, - Locked, - Finished, + Draft = 0, + VotingReady = 1, + Locked = 2, + Finished = 3, } impl ProposalStatus { - pub fn to_plutus_data(self) -> PlutusData { - constr(self as u64, vec![]) + pub fn to_plutus_data(self) -> DaoResult { + int(self as i128) } pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { - let (idx, fields) = as_constr(pd)?; - if !fields.is_empty() { - return Err(DaoError::Datum(format!( - "ProposalStatus expects 0 fields, got {}", - fields.len() - ))); - } - Ok(match idx { + let n = as_int(pd)?; + Ok(match n { 0 => ProposalStatus::Draft, 1 => ProposalStatus::VotingReady, 2 => ProposalStatus::Locked, 3 => ProposalStatus::Finished, other => { return Err(DaoError::Datum(format!( - "ProposalStatus expects 0..=3, got {other}" + "ProposalStatus expects integer 0..=3, got {other}" ))) } }) @@ -201,7 +201,7 @@ impl ProposalDatum { Ok(product(vec![ int(self.proposal_id as i128)?, self.effects_raw.clone(), - self.status.to_plutus_data(), + self.status.to_plutus_data()?, PlutusData::Array(MaybeIndefArray::Indef(cosigners_pd)), self.thresholds.to_plutus_data()?, self.votes.to_plutus_data()?, @@ -266,16 +266,17 @@ mod tests { use super::*; #[test] - fn proposal_status_indices() { - for (s, i) in [ - (ProposalStatus::Draft, 0u64), + fn proposal_status_encodes_as_integer() { + // Per Plutarch EnumIsData in this Agora version (verified against + // Sulkta's Proposal #0 on chain): plain Integer, NOT Constr i []. + for (s, expected) in [ + (ProposalStatus::Draft, 0i128), (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); + let pd = s.to_plutus_data().unwrap(); + assert_eq!(as_int(&pd).unwrap(), expected, "{:?}", s); assert_eq!(ProposalStatus::from_plutus_data(&pd).unwrap(), s); } } @@ -328,6 +329,39 @@ mod tests { } } + /// Decode Sulkta's Proposal #0 from on-chain bytes. Real-world + /// regression for the type port — same role as the GovernorDatum + /// live-decode test, but for a proposal. + /// + /// Source: Koios `address_info` for proposal validator address + /// `addr1w8uydw7xer6wml2pgpv8jphdenxrgwpmdxwaqfj2h5sk45sj8nk40`, + /// only UTxO at `0823a9406da1...#0`. Captured 2026-05-05. + #[test] + fn decodes_sulkta_live_proposal_zero() { + use pallas_primitives::PlutusData; + + let cbor_hex = "9f00a200a001a1581c92b7725bb0c7c06083af729d38f5589c2f85f16c83fe48860399e96f9f5820046dff6c96199a55715c65b9ae62c3be1b6600038cc3116eeb20886a7d66e83cd87a80ff039fd8799f581cc5e3425f44c1909b6caab4d80d88aebe85f328bd209eeab03ca2bfdaffff9f14186418640101ffa2000001009f1a240c84001a240c84001a0a4cb8001a05265c001a0036ee801a001b7740ff1b0000019c7d5c4d17ff"; + let bytes = hex::decode(cbor_hex).unwrap(); + let pd: PlutusData = pallas_codec::minicbor::decode(&bytes).unwrap(); + let prop = ProposalDatum::from_plutus_data(&pd).expect("decode Proposal #0"); + + assert_eq!(prop.proposal_id, 0); + assert_eq!(prop.status, ProposalStatus::Finished); + assert_eq!(prop.cosigners.len(), 1); + // Cobb's pkh + assert!(matches!( + &prop.cosigners[0], + Credential::PubKey(h) if hex::encode(h) == "c5e3425f44c1909b6caab4d80d88aebe85f328bd209eeab03ca2bfda" + )); + assert_eq!(prop.thresholds.execute, 20); + assert_eq!(prop.thresholds.create, 100); + assert_eq!(prop.thresholds.vote, 1); + assert_eq!(prop.votes.0, vec![(0, 0), (1, 0)]); // zero votes ever cast + assert_eq!(prop.timing_config.draft_time, 7 * 86_400 * 1000); + assert_eq!(prop.timing_config.voting_time, 7 * 86_400 * 1000); + assert_eq!(prop.starting_time, 1_772_666_551_575); + } + #[test] fn proposal_datum_round_trip_minimal() { let pd_unit = constr(0, vec![]); // opaque effects placeholder