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:
parent
c059c1ff1c
commit
d1167b5a15
4 changed files with 142 additions and 79 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue