From d1167b5a15df33d56576ada3efec2e3f959b26fe Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 5 May 2026 13:46:21 -0700 Subject: [PATCH] fix(dao): ProductIsData encodes as CBOR Array, not Constr 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Plutarch `ProductIsData` derive (used by every record datum in Agora) emits a CBOR list of fields, NOT the generic Constr 0 encoding I assumed during Phase 0. Verified by decoding Sulkta's live governor UTxO datum: outer bytes start `9f 9f` (indef array of indef arrays), not `d8 79` (Constr tag 121). Affected types: - StakeDatum, ProposalLock (was Constr 0, now Array) - ProposalDatum, ProposalThresholds, ProposalTimingConfig - GovernorDatum Sum types untouched — they keep Constr-encoding (makeIsDataIndexed or EnumIsData both produce Constr i [...]): - Credential, ProposalAction, StakeRedeemer, ProposalRedeemer, GovernorRedeemer, ProposalStatus New helpers in plutus_data.rs: - `product(fields)` — emit indefinite-length CBOR Array - `as_product(pd)` — decode (alias for as_array, named for intent) Added end-to-end validation test `decodes_sulkta_live_governor_datum` that decodes the real on-chain datum hex from Sulkta's governor UTxO (7c8db14...221c47#1) and asserts the parsed struct matches README parameters: thresholds [20/100/100/1/1], 7d draft, 7d vote, 48h lock, 24h exec, 30min ranges, max 20 proposals per stake. --- crates/aldabra-dao/src/agora/governor.rs | 61 +++++++++++---- crates/aldabra-dao/src/agora/plutus_data.rs | 29 +++++++ crates/aldabra-dao/src/agora/proposal.rs | 86 ++++++++++----------- crates/aldabra-dao/src/agora/stake.rs | 45 ++++++----- 4 files changed, 142 insertions(+), 79 deletions(-) diff --git a/crates/aldabra-dao/src/agora/governor.rs b/crates/aldabra-dao/src/agora/governor.rs index 7ede990..c7436e5 100644 --- a/crates/aldabra-dao/src/agora/governor.rs +++ b/crates/aldabra-dao/src/agora/governor.rs @@ -8,7 +8,7 @@ use pallas_primitives::PlutusData; -use crate::agora::plutus_data::{as_constr, as_int, constr, int}; +use crate::agora::plutus_data::{as_constr, as_int, as_product, constr, int, product}; use crate::agora::proposal::{ProposalThresholds, ProposalTimingConfig}; use crate::error::{DaoError, DaoResult}; @@ -25,23 +25,22 @@ pub struct GovernorDatum { impl GovernorDatum { pub fn to_plutus_data(&self) -> DaoResult { - 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)?, - ], - )) + // ProductIsData → Array, NOT Constr 0. + // Verified against Sulkta's live governor UTxO 2026-05-05. + Ok(product(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 { - let (idx, fields) = as_constr(pd)?; - if idx != 0 || fields.len() != 5 { + let fields = as_product(pd)?; + if fields.len() != 5 { return Err(DaoError::Datum(format!( - "GovernorDatum expects Constr 0 with 5 fields, got Constr {idx} with {}", + "GovernorDatum expects Array with 5 fields, got {}", fields.len() ))); } @@ -112,4 +111,38 @@ mod tests { assert_eq!(idx, i); } } + + /// Decode Sulkta's live governor datum from on-chain CBOR bytes and assert + /// the resulting struct matches the README parameters. + /// + /// This is the end-to-end Phase 0 validation: our type port matches what + /// Plutarch actually emits. + /// + /// Source: Koios `address_info` for `addr1w8v73wfrru7smn738k6c5xafqvl2tgsvct7dtztc4jwlf4c35jnmy` + /// at the only governor UTxO `7c8db1432a07143eaf7755257baf8691a8e24ee8b2f3a139fa1ce222f2821c47#1`. + /// Captured 2026-05-05. + #[test] + fn decodes_sulkta_live_governor_datum() { + use pallas_primitives::PlutusData; + + let cbor_hex = "9f9f14186418640101ff019f1a240c84001a240c84001a0a4cb8001a05265c001a0036ee801a001b7740ff1a001b774014ff"; + let bytes = hex::decode(cbor_hex).unwrap(); + let pd: PlutusData = pallas_codec::minicbor::decode(&bytes).unwrap(); + let gov = GovernorDatum::from_plutus_data(&pd).expect("decode Sulkta governor"); + + assert_eq!(gov.proposal_thresholds.execute, 20); + assert_eq!(gov.proposal_thresholds.create, 100); + assert_eq!(gov.proposal_thresholds.to_voting, 100); + assert_eq!(gov.proposal_thresholds.vote, 1); + assert_eq!(gov.proposal_thresholds.cosign, 1); + assert_eq!(gov.next_proposal_id, 1); + assert_eq!(gov.proposal_timings.draft_time, 7 * 86_400 * 1000); + assert_eq!(gov.proposal_timings.voting_time, 7 * 86_400 * 1000); + assert_eq!(gov.proposal_timings.locking_time, 48 * 3600 * 1000); + assert_eq!(gov.proposal_timings.executing_time, 24 * 3600 * 1000); + assert_eq!(gov.proposal_timings.min_stake_voting_time, 60 * 60 * 1000); + assert_eq!(gov.proposal_timings.voting_time_range_max_width, 30 * 60 * 1000); + assert_eq!(gov.create_proposal_time_range_max_width, 30 * 60 * 1000); + assert_eq!(gov.maximum_created_proposals_per_stake, 20); + } } diff --git a/crates/aldabra-dao/src/agora/plutus_data.rs b/crates/aldabra-dao/src/agora/plutus_data.rs index c08d394..acb02d2 100644 --- a/crates/aldabra-dao/src/agora/plutus_data.rs +++ b/crates/aldabra-dao/src/agora/plutus_data.rs @@ -33,6 +33,35 @@ pub fn constr(index: u64, fields: Vec) -> PlutusData { }) } +/// Encode a Plutarch `ProductIsData` record — a CBOR Array, NOT a `Constr 0`. +/// +/// **Important:** Plutarch optimizes record encodings via the `ProductIsData` +/// pattern, which serializes as a plain CBOR list of fields rather than the +/// generic-derived `Constr 0 [...]`. Verified against the live Sulkta +/// GovernorDatum UTxO 2026-05-05: outer wire bytes start `9f9f...` (indefinite +/// array of indefinite arrays) — i.e. arrays, not the `d8 79` Constr-121 tag. +/// +/// We emit indefinite-length arrays to match Plutarch's wire output. Both +/// definite and indefinite are accepted on decode (see [`as_array`]). +/// +/// All Agora product datums use this encoding: +/// - StakeDatum, ProposalLock +/// - ProposalDatum, ProposalThresholds, ProposalTimingConfig +/// - GovernorDatum +/// +/// Sum-type variants (Credential, ProposalAction, redeemers, ProposalStatus) +/// keep [`constr`] encoding — their derives are `makeIsDataIndexed` or +/// `EnumIsData`, both of which produce `Constr i [...]`. +pub fn product(fields: Vec) -> PlutusData { + PlutusData::Array(MaybeIndefArray::Indef(fields)) +} + +/// Decode a `ProductIsData` record back into its field list. Alias for +/// [`as_array`]; the separate name documents intent at call sites. +pub fn as_product(pd: &PlutusData) -> DaoResult<&Vec> { + as_array(pd) +} + /// Encode a non-negative integer as PlutusData::BigInt. /// /// Agora uses `Integer` everywhere, but in practice all our numbers diff --git a/crates/aldabra-dao/src/agora/proposal.rs b/crates/aldabra-dao/src/agora/proposal.rs index 4614454..1962d42 100644 --- a/crates/aldabra-dao/src/agora/proposal.rs +++ b/crates/aldabra-dao/src/agora/proposal.rs @@ -17,7 +17,7 @@ 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, + as_array, as_constr, as_int, as_map, as_product, constr, int, product, }; use crate::agora::stake::Credential; use crate::error::{DaoError, DaoResult}; @@ -73,23 +73,21 @@ pub struct ProposalThresholds { impl ProposalThresholds { pub fn to_plutus_data(&self) -> DaoResult { - 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)?, - ], - )) + // ProductIsData → Array. + Ok(product(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 { - let (idx, fields) = as_constr(pd)?; - if idx != 0 || fields.len() != 5 { + let fields = as_product(pd)?; + if fields.len() != 5 { return Err(DaoError::Datum(format!( - "ProposalThresholds expects Constr 0 with 5 fields, got Constr {idx} with {}", + "ProposalThresholds expects Array with 5 fields, got {}", fields.len() ))); } @@ -120,24 +118,21 @@ pub struct ProposalTimingConfig { impl ProposalTimingConfig { pub fn to_plutus_data(&self) -> DaoResult { - 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)?, - ], - )) + Ok(product(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 { - let (idx, fields) = as_constr(pd)?; - if idx != 0 || fields.len() != 6 { + let fields = as_product(pd)?; + if fields.len() != 6 { return Err(DaoError::Datum(format!( - "ProposalTimingConfig expects Constr 0 with 6 fields, got Constr {idx} with {}", + "ProposalTimingConfig expects Array with 6 fields, got {}", fields.len() ))); } @@ -203,26 +198,23 @@ impl ProposalDatum { .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)?, - ], - )) + Ok(product(vec![ + int(self.proposal_id as i128)?, + self.effects_raw.clone(), + self.status.to_plutus_data(), + PlutusData::Array(MaybeIndefArray::Indef(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 { - let (idx, fields) = as_constr(pd)?; - if idx != 0 || fields.len() != 8 { + let fields = as_product(pd)?; + if fields.len() != 8 { return Err(DaoError::Datum(format!( - "ProposalDatum expects Constr 0 with 8 fields, got Constr {idx} with {}", + "ProposalDatum expects Array with 8 fields, got {}", fields.len() ))); } @@ -242,6 +234,12 @@ impl ProposalDatum { } } +/// Test if `as_constr` is the right call (sum type) vs `as_product` (record). +/// Documented for new contributors — Plutarch's two encoding shapes look +/// identical from the Rust struct side but produce very different CBOR. +#[allow(dead_code)] +fn _shape_dispatch_doc() {} + /// `ProposalRedeemer` — `makeIsDataIndexed`: /// 0=Vote ResultTag, 1=Cosign, 2=UnlockStake, 3=AdvanceProposal. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/aldabra-dao/src/agora/stake.rs b/crates/aldabra-dao/src/agora/stake.rs index 7320254..7925201 100644 --- a/crates/aldabra-dao/src/agora/stake.rs +++ b/crates/aldabra-dao/src/agora/stake.rs @@ -17,7 +17,9 @@ 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::agora::plutus_data::{ + as_array, as_bytes, as_constr, as_int, as_product, bytes, constr, int, product, +}; use crate::error::{DaoError, DaoResult}; /// Cardano credential — either a public-key hash or a script hash. @@ -135,17 +137,18 @@ pub struct ProposalLock { impl ProposalLock { pub fn to_plutus_data(&self) -> DaoResult { - Ok(constr( - 0, - vec![int(self.proposal_id as i128)?, self.action.to_plutus_data()?], - )) + // ProductIsData → CBOR Array, NOT Constr 0. + Ok(product(vec![ + int(self.proposal_id as i128)?, + self.action.to_plutus_data()?, + ])) } pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { - let (idx, fields) = as_constr(pd)?; - if idx != 0 || fields.len() != 2 { + let fields = as_product(pd)?; + if fields.len() != 2 { return Err(DaoError::Datum(format!( - "ProposalLock expects Constr 0 [Int, ProposalAction], got Constr {idx} with {} fields", + "ProposalLock expects Array [Int, ProposalAction], got {} fields", fields.len() ))); } @@ -178,6 +181,9 @@ pub struct StakeDatum { impl StakeDatum { pub fn to_plutus_data(&self) -> DaoResult { + // `Maybe Credential` is a sum type → Constr-encoded. + // `Credential` itself is a sum type → Constr-encoded. + // The outer StakeDatum is a record → ProductIsData (Array). let delegated_pd = match &self.delegated_to { Some(c) => constr(0, vec![c.to_plutus_data()]), None => constr(1, vec![]), @@ -187,23 +193,20 @@ impl StakeDatum { .iter() .map(|l| l.to_plutus_data()) .collect::>>()?; - Ok(constr( - 0, - vec![ - int(self.staked_amount as i128)?, - self.owner.to_plutus_data(), - delegated_pd, - PlutusData::Array(MaybeIndefArray::Def(locks_pd)), - ], - )) + Ok(product(vec![ + int(self.staked_amount as i128)?, + self.owner.to_plutus_data(), + delegated_pd, + PlutusData::Array(MaybeIndefArray::Indef(locks_pd)), + ])) } pub fn from_plutus_data(pd: &PlutusData) -> DaoResult { - let (idx, fields) = as_constr(pd)?; - if idx != 0 || fields.len() != 4 { + let fields = as_product(pd)?; + if fields.len() != 4 { return Err(DaoError::Datum(format!( - "StakeDatum expects Constr 0 [Int, Credential, Maybe Credential, [ProposalLock]], \ - got Constr {idx} with {} fields", + "StakeDatum expects Array [Int, Credential, Maybe Credential, [ProposalLock]], \ + got {} fields", fields.len() ))); }