fix(dao): ProductIsData encodes as CBOR Array, not Constr 0

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.
This commit is contained in:
Kayos 2026-05-05 13:46:21 -07:00
parent c059c1ff1c
commit d1167b5a15
4 changed files with 142 additions and 79 deletions

View file

@ -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<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)?,
],
))
// 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<Self> {
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);
}
}

View file

@ -33,6 +33,35 @@ pub fn constr(index: u64, fields: Vec<PlutusData>) -> 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 {
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<PlutusData>> {
as_array(pd)
}
/// Encode a non-negative integer as PlutusData::BigInt.
///
/// Agora uses `Integer` everywhere, but in practice all our numbers

View file

@ -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<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)?,
],
))
// 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<Self> {
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<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)?,
],
))
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<Self> {
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<Self> {
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)]

View file

@ -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<PlutusData> {
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<Self> {
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<PlutusData> {
// `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::<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)),
],
))
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<Self> {
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()
)));
}