fix(dao): EnumIsData → plain Integer, not Constr i []

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.
This commit is contained in:
Kayos 2026-05-05 15:41:03 -07:00
parent 5fb616c6c5
commit a8ecdfa45d
2 changed files with 76 additions and 35 deletions

View file

@ -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<PlutusData> {
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);
}
}

View file

@ -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<PlutusData> {
int(self as i128)
}
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
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