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=[Sulkta'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 internal notes.
This commit is contained in:
parent
173abb9322
commit
bcfb98db1e
2 changed files with 76 additions and 35 deletions
|
|
@ -56,16 +56,23 @@ impl GovernorDatum {
|
||||||
|
|
||||||
/// `GovernorRedeemer` — EnumIsData (declaration order):
|
/// `GovernorRedeemer` — EnumIsData (declaration order):
|
||||||
/// 0=CreateProposal, 1=MintGATs, 2=MutateGovernor.
|
/// 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum GovernorRedeemer {
|
pub enum GovernorRedeemer {
|
||||||
CreateProposal,
|
CreateProposal = 0,
|
||||||
MintGATs,
|
MintGATs = 1,
|
||||||
MutateGovernor,
|
MutateGovernor = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GovernorRedeemer {
|
impl GovernorRedeemer {
|
||||||
pub fn to_plutus_data(self) -> PlutusData {
|
pub fn to_plutus_data(self) -> DaoResult<PlutusData> {
|
||||||
constr(self as u64, vec![])
|
int(self as i128)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,16 +107,16 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn governor_redeemer_indices() {
|
fn governor_redeemer_encodes_as_integer() {
|
||||||
use crate::agora::plutus_data::as_constr;
|
// Per Plutarch EnumIsData (matches ProposalStatus shape verified
|
||||||
for (r, i) in [
|
// on chain). Plain Integer, not Constr i [].
|
||||||
(GovernorRedeemer::CreateProposal, 0u64),
|
for (r, expected) in [
|
||||||
|
(GovernorRedeemer::CreateProposal, 0i128),
|
||||||
(GovernorRedeemer::MintGATs, 1),
|
(GovernorRedeemer::MintGATs, 1),
|
||||||
(GovernorRedeemer::MutateGovernor, 2),
|
(GovernorRedeemer::MutateGovernor, 2),
|
||||||
] {
|
] {
|
||||||
let pd = r.to_plutus_data();
|
let pd = r.to_plutus_data().unwrap();
|
||||||
let (idx, _) = as_constr(&pd).unwrap();
|
assert_eq!(as_int(&pd).unwrap(), expected, "{:?}", r);
|
||||||
assert_eq!(idx, i);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,36 +23,36 @@ use crate::agora::stake::Credential;
|
||||||
use crate::error::{DaoError, DaoResult};
|
use crate::error::{DaoError, DaoResult};
|
||||||
|
|
||||||
/// `data ProposalStatus = Draft | VotingReady | Locked | Finished`
|
/// `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 internal notes.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ProposalStatus {
|
pub enum ProposalStatus {
|
||||||
Draft,
|
Draft = 0,
|
||||||
VotingReady,
|
VotingReady = 1,
|
||||||
Locked,
|
Locked = 2,
|
||||||
Finished,
|
Finished = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProposalStatus {
|
impl ProposalStatus {
|
||||||
pub fn to_plutus_data(self) -> PlutusData {
|
pub fn to_plutus_data(self) -> DaoResult<PlutusData> {
|
||||||
constr(self as u64, vec![])
|
int(self as i128)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
pub fn from_plutus_data(pd: &PlutusData) -> DaoResult<Self> {
|
||||||
let (idx, fields) = as_constr(pd)?;
|
let n = as_int(pd)?;
|
||||||
if !fields.is_empty() {
|
Ok(match n {
|
||||||
return Err(DaoError::Datum(format!(
|
|
||||||
"ProposalStatus expects 0 fields, got {}",
|
|
||||||
fields.len()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(match idx {
|
|
||||||
0 => ProposalStatus::Draft,
|
0 => ProposalStatus::Draft,
|
||||||
1 => ProposalStatus::VotingReady,
|
1 => ProposalStatus::VotingReady,
|
||||||
2 => ProposalStatus::Locked,
|
2 => ProposalStatus::Locked,
|
||||||
3 => ProposalStatus::Finished,
|
3 => ProposalStatus::Finished,
|
||||||
other => {
|
other => {
|
||||||
return Err(DaoError::Datum(format!(
|
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![
|
Ok(product(vec![
|
||||||
int(self.proposal_id as i128)?,
|
int(self.proposal_id as i128)?,
|
||||||
self.effects_raw.clone(),
|
self.effects_raw.clone(),
|
||||||
self.status.to_plutus_data(),
|
self.status.to_plutus_data()?,
|
||||||
PlutusData::Array(MaybeIndefArray::Indef(cosigners_pd)),
|
PlutusData::Array(MaybeIndefArray::Indef(cosigners_pd)),
|
||||||
self.thresholds.to_plutus_data()?,
|
self.thresholds.to_plutus_data()?,
|
||||||
self.votes.to_plutus_data()?,
|
self.votes.to_plutus_data()?,
|
||||||
|
|
@ -266,16 +266,17 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn proposal_status_indices() {
|
fn proposal_status_encodes_as_integer() {
|
||||||
for (s, i) in [
|
// Per Plutarch EnumIsData in this Agora version (verified against
|
||||||
(ProposalStatus::Draft, 0u64),
|
// Sulkta's Proposal #0 on chain): plain Integer, NOT Constr i [].
|
||||||
|
for (s, expected) in [
|
||||||
|
(ProposalStatus::Draft, 0i128),
|
||||||
(ProposalStatus::VotingReady, 1),
|
(ProposalStatus::VotingReady, 1),
|
||||||
(ProposalStatus::Locked, 2),
|
(ProposalStatus::Locked, 2),
|
||||||
(ProposalStatus::Finished, 3),
|
(ProposalStatus::Finished, 3),
|
||||||
] {
|
] {
|
||||||
let pd = s.to_plutus_data();
|
let pd = s.to_plutus_data().unwrap();
|
||||||
let (idx, _) = as_constr(&pd).unwrap();
|
assert_eq!(as_int(&pd).unwrap(), expected, "{:?}", s);
|
||||||
assert_eq!(idx, i);
|
|
||||||
assert_eq!(ProposalStatus::from_plutus_data(&pd).unwrap(), 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);
|
||||||
|
// second owner 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]
|
#[test]
|
||||||
fn proposal_datum_round_trip_minimal() {
|
fn proposal_datum_round_trip_minimal() {
|
||||||
let pd_unit = constr(0, vec![]); // opaque effects placeholder
|
let pd_unit = constr(0, vec![]); // opaque effects placeholder
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue