From 2f3a9636a9516a1d9fcf82f7e86d933d845236d2 Mon Sep 17 00:00:00 2001 From: yHSJ Date: Mon, 27 Jul 2026 17:34:39 -0500 Subject: [PATCH 1/5] fix(amaru-ledger): ensures voters are registered entity when validating transactions Co-authored-by: KtorZ Signed-off-by: yHSJ --- CHANGELOG.md | 6 + .../amaru-kernel/src/cardano/proposal_id.rs | 15 +-- .../amaru-kernel/src/cardano/proposal_kind.rs | 48 ++++++- .../src/cardano/proposals_roots.rs | 3 +- .../state/volatile_db/common/scenario.rs | 2 +- crates/amaru-ledger/src/bootstrap.rs | 2 +- crates/amaru-ledger/src/context.rs | 19 ++- .../src/context/default/preparation.rs | 118 ++++++++++++------ .../src/context/default/validation.rs | 82 +++++++++++- .../ratification/proposals_forest.rs | 2 +- crates/amaru-ledger/src/rules.rs | 36 ++++-- .../rules/transaction/phase_one/fixture.rs | 93 +++++++------- .../src/rules/transaction/phase_one/mod.rs | 8 +- .../rules/transaction/phase_one/proposals.rs | 2 +- .../phase_one/voting_procedures.rs | 75 +++++++---- crates/amaru-ledger/src/snapshot.rs | 2 +- crates/amaru-ledger/src/state/volatile.rs | 6 + .../src/state/volatile/aggregate.rs | 7 ++ .../state/volatile/aggregate/indexed_set.rs | 6 + crates/amaru-ledger/src/state/volatile/db.rs | 8 ++ .../amaru-ledger/src/state/volatile/series.rs | 4 + .../tests/data/phase-one/README.md | 13 +- .../fail/InvalidPrevGovActionId/1.json | 19 +-- .../phase-one/fail/VotersDoNotExist/0.json | 30 +++++ .../phase-one/fail/VotersDoNotExist/1.json | 30 +++++ .../phase-one/fail/VotersDoNotExist/2.json | 36 ++++++ .../phase-one/fail/VotersDoNotExist/3.json | 30 +++++ .../phase-one/fail/VotersDoNotExist/4.json | 37 ++++++ .../phase-one/fail/VotersDoNotExist/5.json | 37 ++++++ .../phase-one/fail/VotersDoNotExist/6.json | 37 ++++++ .../phase-one/fail/VotersDoNotExist/7.json | 37 ++++++ .../phase-one/fail/VotersDoNotExist/8.json | 30 +++++ ...hardfork-initiation-follows-in-flight.json | 16 +-- ...ov-new-constitution-follows-in-flight.json | 8 +- ...ov-parameter-change-follows-in-flight.json | 8 +- ...ov-update-committee-follows-in-flight.json | 8 +- .../pass/vote-committee-hot-key.json | 35 ++++++ .../pass/vote-committee-hot-script.json | 35 ++++++ .../pass/vote-committee-unelected-member.json | 35 ++++++ .../pass/vote-drep-expired-registration.json | 42 +++++++ .../pass/vote-drep-key-credential.json | 42 +++++++ .../pass/vote-script-credential.json | 23 +++- .../data/phase-one/pass/vote-stake-pool.json | 31 +++++ .../tests/data/phase-one/schema.json | 39 +++++- .../data/rules-conformance.failures.toml | 1 - crates/amaru-observability/src/schemas.rs | 2 + crates/amaru-plutus/src/script_context/v3.rs | 2 +- docs/TRACES.md | 3 +- docs/traces-schema.json | 6 +- 49 files changed, 1015 insertions(+), 201 deletions(-) create mode 100644 crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/0.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/1.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/2.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/3.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/4.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/5.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/6.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/7.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/8.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/pass/vote-committee-hot-key.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/pass/vote-committee-hot-script.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/pass/vote-committee-unelected-member.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/pass/vote-drep-expired-registration.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/pass/vote-drep-key-credential.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/pass/vote-stake-pool.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 432373546c..d5e6c4cf2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ Other guiding principles: ## v10.11.20260813 _[unreleased; planned for 2026-08-13]_ +### Changed + +- **amaru-ledger**: validate voters in a transaction actually exist in ledger state. ([#1138][], [#923][]) + ### Fixed - **amaru-ledger**: reject governance proposals whose previous action does not match the enacted root nor an in-flight proposal of the same purpose. ([#1090][], [#932][]) @@ -270,6 +274,7 @@ Other guiding principles: [#909]: https://github.com/pragma-org/amaru/issues/909 [#912]: https://github.com/pragma-org/amaru/issues/912 [#915]: https://github.com/pragma-org/amaru/issues/915 +[#923]: https://github.com/pragma-org/amaru/issues/923 [#928]: https://github.com/pragma-org/amaru/issues/928 [#929]: https://github.com/pragma-org/amaru/issues/929 [#932]: https://github.com/pragma-org/amaru/issues/932 @@ -324,3 +329,4 @@ Other guiding principles: [#1101]: https://github.com/pragma-org/amaru/pull/1101 [#1109]: https://github.com/pragma-org/amaru/pull/1109 [#1118]: https://github.com/pragma-org/amaru/pull/1118 +[#1138]: https://github.com/pragma-org/amaru/pull/1138 diff --git a/crates/amaru-kernel/src/cardano/proposal_id.rs b/crates/amaru-kernel/src/cardano/proposal_id.rs index a6ba04b82f..a29019a717 100644 --- a/crates/amaru-kernel/src/cardano/proposal_id.rs +++ b/crates/amaru-kernel/src/cardano/proposal_id.rs @@ -17,21 +17,22 @@ use std::fmt; use crate::{Hash, cbor, size::TRANSACTION_BODY}; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, std::hash::Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ProposalId { pub transaction_id: Hash<{ TRANSACTION_BODY }>, - pub action_index: u32, + pub proposal_index: u32, } impl fmt::Display for ProposalId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}#{}", self.transaction_id, self.action_index) + write!(f, "{}#{}", self.transaction_id, self.proposal_index) } } impl ProposalId { /// Like `Display`, but more compact pub fn to_compact_string(&self) -> String { - format!("{}#{}", self.action_index, self.transaction_id.to_string().chars().take(8).collect::()) + format!("{}#{}", self.proposal_index, self.transaction_id.to_string().chars().take(8).collect::()) } } @@ -43,7 +44,7 @@ impl cbor::Encode for ProposalId { ) -> Result<(), cbor::encode::Error> { e.array(2)?; e.encode_with(self.transaction_id, ctx)?; - e.encode_with(self.action_index, ctx)?; + e.encode_with(self.proposal_index, ctx)?; Ok(()) } } @@ -52,7 +53,7 @@ impl<'b, C> cbor::Decode<'b, C> for ProposalId { fn decode(d: &mut cbor::Decoder<'b>, ctx: &mut C) -> Result { d.array()?; - Ok(Self { transaction_id: d.decode_with(ctx)?, action_index: d.decode_with(ctx)? }) + Ok(Self { transaction_id: d.decode_with(ctx)?, proposal_index: d.decode_with(ctx)? }) } } @@ -71,11 +72,11 @@ mod tests { prop_compose! { pub fn any_proposal_id()( transaction_id in any::<[u8; 32]>(), - action_index in any::(), + proposal_index in any::(), ) -> ProposalId { ProposalId { transaction_id: Hash::new(transaction_id), - action_index, + proposal_index, } } } diff --git a/crates/amaru-kernel/src/cardano/proposal_kind.rs b/crates/amaru-kernel/src/cardano/proposal_kind.rs index 698ba4bfe8..922033c1d1 100644 --- a/crates/amaru-kernel/src/cardano/proposal_kind.rs +++ b/crates/amaru-kernel/src/cardano/proposal_kind.rs @@ -12,11 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::{fmt, str::FromStr}; + use crate::GovernanceAction; /// A type capturing just the proposal group/kind another proposal belong. The kind determines the /// lineage the proposal belongs to. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)] +#[serde(try_from = "&str", into = "String")] pub enum ProposalKind { ProtocolParameters, HardFork, @@ -25,6 +28,49 @@ pub enum ProposalKind { Orphan, } +impl TryFrom<&str> for ProposalKind { + type Error = ::Err; + fn try_from(s: &str) -> Result { + ProposalKind::from_str(s) + } +} + +impl From for String { + fn from(kind: ProposalKind) -> Self { + kind.to_string() + } +} + +impl fmt::Display for ProposalKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::ProtocolParameters => "ProtocolParameters", + Self::HardFork => "HardFork", + Self::ConstitutionalCommittee => "ConstitutionalCommittee", + Self::Constitution => "Constitution", + Self::Orphan => "Orphan", + } + ) + } +} + +impl FromStr for ProposalKind { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "ProtocolParameters" => Ok(Self::ProtocolParameters), + "HardFork" => Ok(Self::HardFork), + "ConstitutionalCommittee" => Ok(Self::ConstitutionalCommittee), + "Constitution" => Ok(Self::Constitution), + "Orphan" => Ok(Self::Orphan), + s => Err(s.to_string()), + } + } +} + impl From<&GovernanceAction> for ProposalKind { fn from(action: &GovernanceAction) -> Self { use GovernanceAction::*; diff --git a/crates/amaru-kernel/src/cardano/proposals_roots.rs b/crates/amaru-kernel/src/cardano/proposals_roots.rs index 1d067c32d2..10fb0161bb 100644 --- a/crates/amaru-kernel/src/cardano/proposals_roots.rs +++ b/crates/amaru-kernel/src/cardano/proposals_roots.rs @@ -20,7 +20,8 @@ pub type ProposalsRoots = GenericProposalsRoots; pub type ProposalsRootsRc = GenericProposalsRoots>; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] pub struct GenericProposalsRoots { pub protocol_parameters: Option, pub hard_fork: Option, diff --git a/crates/amaru-ledger/benches/state/volatile_db/common/scenario.rs b/crates/amaru-ledger/benches/state/volatile_db/common/scenario.rs index e05bcb9f6b..84752e3c7f 100644 --- a/crates/amaru-ledger/benches/state/volatile_db/common/scenario.rs +++ b/crates/amaru-ledger/benches/state/volatile_db/common/scenario.rs @@ -259,7 +259,7 @@ impl Scenario { std::iter::empty() .chain(fragment.dreps.registered.keys()) .chain(fragment.dreps.unregistered.iter()) - .for_each(|drep| ctx.require_drep(drep)); + .for_each(|drep| ctx.require_drep(Cow::Borrowed(drep))); } Self::Proposals => { /* Nothing to do, because we generate Information proposals */ } Self::Votes => { diff --git a/crates/amaru-ledger/src/bootstrap.rs b/crates/amaru-ledger/src/bootstrap.rs index 0ee2494e91..0353188a71 100644 --- a/crates/amaru-ledger/src/bootstrap.rs +++ b/crates/amaru-ledger/src/bootstrap.rs @@ -641,7 +641,7 @@ fn import_proposals( proposals: proposals .iter() .map(|proposal| -> Result<_, Box> { - let proposal_index = proposal.id.action_index as usize; + let proposal_index = proposal.id.proposal_index as usize; Ok(( proposal.id, proposals::Value { diff --git a/crates/amaru-ledger/src/context.rs b/crates/amaru-ledger/src/context.rs index ff89d64b85..b0adccbf78 100644 --- a/crates/amaru-ledger/src/context.rs +++ b/crates/amaru-ledger/src/context.rs @@ -274,7 +274,9 @@ pub trait DRepsSlice { /// An interface to help constructing the concrete DRepsSlice ahead of time. pub trait PrepareDRepsSlice<'a> { - fn require_drep(&'_ mut self, credential: &'a StakeCredential); + /// Require a DRep registration. Borrowed when a certificate names the credential directly, owned + /// when it has to be built from a `Voter`, which carries a bare hash rather than a credential. + fn require_drep(&'_ mut self, credential: Cow<'a, StakeCredential>); /// Require the DRep targeted by a vote delegation. The credential is constructed from the `DRep` /// at resolution (and `Abstain`/`NoConfidence` drop out), so the borrowed `DRep` is collected. @@ -284,7 +286,7 @@ pub trait PrepareDRepsSlice<'a> { // Constitutional Committee // ------------------------------------------------------------------------------------------------- -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct CCMember { /// The authorized hot credential, if the member has declared one. pub hot_credential: Option, @@ -298,6 +300,13 @@ pub trait CommitteeSlice { /// hot-key change folded in, or `None` once it has resigned. fn lookup(&self, cc_member: &StakeCredential) -> Option; + /// Every member currently authorizing this hot credential, which is the identity a vote carries. + /// Empty when none does, including when one authorized it earlier but has since rotated to + /// another hot key or resigned. + /// + /// Set-valued because a hot credential is not unique to a member. + fn lookup_by_hot_credential(&self, hot_credential: &StakeCredential) -> BTreeSet; + fn delegate_cold_key( &mut self, cc_member: StakeCredential, @@ -314,6 +323,12 @@ pub trait CommitteeSlice { /// An interface to help constructing the concrete CommitteeSlice ahead of time. pub trait PrepareCommitteeSlice<'a> { fn require_committee_member(&'_ mut self, cc_member: &'a StakeCredential); + + /// Require the member authorizing the hot credential a vote was cast with. Unlike + /// [`Self::require_committee_member`] this isn't a store key, so it can only be resolved by + /// scanning the members reachable at block start; the credential is collected here and matched at + /// resolution. + fn require_committee_voter(&'_ mut self, hot_credential: StakeCredential); } // Governance Proposals diff --git a/crates/amaru-ledger/src/context/default/preparation.rs b/crates/amaru-ledger/src/context/default/preparation.rs index 8e6fd28fe8..b592c65886 100644 --- a/crates/amaru-ledger/src/context/default/preparation.rs +++ b/crates/amaru-ledger/src/context/default/preparation.rs @@ -44,9 +44,10 @@ pub struct DefaultPreparationContext<'a> { pub utxo: BTreeSet<&'a TransactionInput>, pub pools: BTreeSet<&'a PoolId>, pub accounts: BTreeSet>, - pub dreps: BTreeSet<&'a StakeCredential>, + pub dreps: BTreeSet>, pub drep_delegations: BTreeSet<&'a DRep>, pub committee: BTreeSet<&'a StakeCredential>, + pub committee_voters: BTreeSet, pub proposals: BTreeSet, } @@ -59,6 +60,7 @@ impl DefaultPreparationContext<'_> { dreps: BTreeSet::new(), drep_delegations: BTreeSet::new(), committee: BTreeSet::new(), + committee_voters: BTreeSet::new(), proposals: BTreeSet::new(), } } @@ -85,7 +87,7 @@ impl<'a> PrepareAccountsSlice<'a> for DefaultPreparationContext<'a> { } impl<'a> PrepareDRepsSlice<'a> for DefaultPreparationContext<'a> { - fn require_drep(&mut self, drep: &'a StakeCredential) { + fn require_drep(&mut self, drep: Cow<'a, StakeCredential>) { self.dreps.insert(drep); } @@ -98,6 +100,10 @@ impl<'a> PrepareCommitteeSlice<'a> for DefaultPreparationContext<'a> { fn require_committee_member(&mut self, cc_member: &'a StakeCredential) { self.committee.insert(cc_member); } + + fn require_committee_voter(&mut self, hot_credential: StakeCredential) { + self.committee_voters.insert(hot_credential); + } } impl<'a> PrepareProposalsSlice<'a> for DefaultPreparationContext<'a> { @@ -136,10 +142,10 @@ impl<'block> DefaultPreparationContext<'block> { db, self.dreps .into_iter() - .cloned() + .map(Cow::into_owned) .chain(self.drep_delegations.into_iter().filter_map(drep::to_stake_credential)), )?, - resolve_committee(volatile, db, self.committee.into_iter())?, + resolve_committee(volatile, db, self.committee.into_iter().cloned(), &self.committee_voters)?, resolve_proposals(volatile, db, self.proposals.into_iter())?, proposal_roots, treasury, @@ -376,61 +382,97 @@ fn resolve_dreps<'volatile>( // member (Haskell's `cgceCommitteeProposals`), which lets a not-yet-elected member pre-declare // its hot key. That source needs the proposals read-path, so it is deferred until proposals are // exposed. -fn resolve_committee<'block, 'volatile>( +fn resolve_committee<'volatile>( volatile: &'volatile impl VolatileState = Existence>>, db: &impl ReadStore, - mut keys: impl Iterator, + cold_credentials: impl Iterator, + hot_credentials: &BTreeSet, ) -> Result, ContextHydratationError> { debug_span!(ledger::validation_context::committee::HYDRATE).in_scope(|| { let mut from_volatile = 0; let mut from_db = 0; + let mut scanned = 0; + let mut cc_members = BTreeMap::new(); - let cc_members = keys.try_fold(BTreeMap::new(), |mut cc_members, credential| { - let member_opt = match volatile.resolve_cc_member(credential) { - Existence::Gone => { - from_volatile += 1; - None - } + for credential in cold_credentials { + if let Some(member) = resolve_committee_member(volatile, db, &credential, &mut from_volatile, &mut from_db)? + { + cc_members.insert(credential, member); + } + } - Existence::Exists(Bind { value, left: hot_credential, .. }) => { - if let Some(valid_until) = value { - from_volatile += 1; - Some(CCMember { - hot_credential: hot_credential.to_option(None), - valid_until: Some(*valid_until), - }) - } else { - db.cc_member(credential).map_err(ContextHydratationError::ResolveCommittee)?.map(|mut row| { - from_db += 1; - hot_credential.owned().set_or_reset(&mut row.hot_credential); - CCMember { hot_credential: row.hot_credential, valid_until: row.valid_until } - }) - } + // A vote names its member by hot credential, which no layer is indexed by. Scan the members + // reachable at block start -- the stable rows, plus any the volatile window touched -- and keep + // the ones a vote refers to. A member whose hot key is authorized by the *ongoing* block is + // reached through its cold credential instead, which that certificate already required. + if !hot_credentials.is_empty() { + let candidates = db + .iter_cc_members() + .map_err(ContextHydratationError::ResolveCommittee)? + .map(|(cold_credential, _)| cold_credential) + .chain(volatile.touched_cc_members().cloned()) + .collect::>(); + + for credential in candidates { + if cc_members.contains_key(&credential) { + continue; } - Existence::Unknown => { - db.cc_member(credential).map_err(ContextHydratationError::ResolveCommittee)?.map(|row| { - from_db += 1; - CCMember { hot_credential: row.hot_credential, valid_until: row.valid_until } - }) - } - }; + scanned += 1; - if let Some(member) = member_opt { - cc_members.insert(*credential, member); + if let Some(member) = + resolve_committee_member(volatile, db, &credential, &mut from_volatile, &mut from_db)? + && member.hot_credential.as_ref().is_some_and(|hot| hot_credentials.contains(hot)) + { + cc_members.insert(credential, member); + } } - - Ok(cc_members) - })?; + } let span = tracing::Span::current(); span.record("from_volatile", from_volatile); span.record("from_db", from_db); + span.record("scanned", scanned); Ok(cc_members) }) } +/// Materialize one committee member, layering the volatile window over the stable store. `None` when +/// the credential belongs to no member, or to one that resigned. +fn resolve_committee_member<'volatile>( + volatile: &'volatile impl VolatileState = Existence>>, + db: &impl ReadStore, + credential: &StakeCredential, + from_volatile: &mut usize, + from_db: &mut usize, +) -> Result, ContextHydratationError> { + Ok(match volatile.resolve_cc_member(credential) { + Existence::Gone => { + *from_volatile += 1; + None + } + + Existence::Exists(Bind { value, left: hot_credential, .. }) => { + if let Some(valid_until) = value { + *from_volatile += 1; + Some(CCMember { hot_credential: hot_credential.to_option(None), valid_until: Some(*valid_until) }) + } else { + db.cc_member(credential).map_err(ContextHydratationError::ResolveCommittee)?.map(|mut row| { + *from_db += 1; + hot_credential.owned().set_or_reset(&mut row.hot_credential); + CCMember { hot_credential: row.hot_credential, valid_until: row.valid_until } + }) + } + } + + Existence::Unknown => db.cc_member(credential).map_err(ContextHydratationError::ResolveCommittee)?.map(|row| { + *from_db += 1; + CCMember { hot_credential: row.hot_credential, valid_until: row.valid_until } + }), + }) +} + /// The materialized [`ProposalState`] for each existing id, layering the ongoing block over the /// volatile DB over the stable store; a `Gone` tombstone (boundary pruning) skips the stale /// stable entry. A proposal still in the volatile window was proposed within the last `k` blocks, diff --git a/crates/amaru-ledger/src/context/default/validation.rs b/crates/amaru-ledger/src/context/default/validation.rs index 1a1cded5ae..2685762c4e 100644 --- a/crates/amaru-ledger/src/context/default/validation.rs +++ b/crates/amaru-ledger/src/context/default/validation.rs @@ -323,15 +323,16 @@ impl DRepsSlice for DefaultValidationContext { } impl CommitteeSlice for DefaultValidationContext { - /// The block start state (`self.committee`) with this block's hot-key change folded in. No - /// in-block cert establishes membership, so a binding only ever layers over an existing member. + /// The block start state (`self.committee`) with this block's hot-key change folded in. An + /// authorization has no block-start row to layer over when its cold credential holds no seat, + /// which is a state a member can be in: the term is what election confers, not the ability to + /// authorize. Applying the certificate materializes exactly this, a row with no term. fn lookup(&self, cc_member: &StakeCredential) -> Option { let base = self.committee.get(cc_member); match self.state.committee.produced.get(cc_member) { Some(hot_credential) => { - let base = base?; - Some(CCMember { hot_credential: Some(*hot_credential), valid_until: base.valid_until }) + Some(CCMember { hot_credential: Some(*hot_credential), valid_until: base.and_then(|b| b.valid_until) }) } // resigned in-block; gone None if self.state.committee.consumed.contains(cc_member) => None, @@ -340,6 +341,27 @@ impl CommitteeSlice for DefaultValidationContext { } } + /// Lookup CC members, based on a hot key, inlcuding those introduced in the current block. + /// + /// Notably, there is no restriction on the hot credentials, + /// so a single hot credential could be used by many cold credentials. + /// See also [`authorizedHotCommitteeCredentials`][haskell] from the Haskell. + /// + /// The length of this set is *NOT* an authoritative count as CCMembers with the same hot key + /// and expiration collapse into one. + /// + /// [haskell]: https://github.com/IntersectMBO/cardano-ledger/blob/0cfbf861cfb456660a7b73281c6fb714a53d40f9/libs/cardano-ledger-core/src/Cardano/Ledger/State/CertState.hs#L335-L337 + fn lookup_by_hot_credential(&self, hot_credential: &StakeCredential) -> BTreeSet { + self.committee + .keys() + .chain(self.state.committee.produced.keys()) + .filter_map(|cc_member| { + let member = CommitteeSlice::lookup(self, cc_member)?; + (member.hot_credential.as_ref() == Some(hot_credential)).then_some(member) + }) + .collect() + } + fn delegate_cold_key( &mut self, cc_member: StakeCredential, @@ -489,6 +511,7 @@ impl BalanceSlice for DefaultValidationContext { #[cfg(test)] mod tests { use amaru_kernel::{Slot, TransactionPointer}; + use test_case::test_case; use super::*; @@ -575,10 +598,61 @@ mod tests { assert_eq!(CommitteeSlice::lookup(&ctx, &cred(1)).and_then(|m| m.hot_credential), Some(cred(2))); } + #[test] + fn committee_lookup_folds_in_an_auth_from_a_cold_credential_holding_no_seat() { + let mut ctx = ctx_with_committee(BTreeMap::new()); + ctx.delegate_cold_key(cred(1), cred(2)).unwrap(); + assert_eq!( + CommitteeSlice::lookup(&ctx, &cred(1)), + Some(CCMember { hot_credential: Some(cred(2)), valid_until: None }) + ); + } + #[test] fn committee_lookup_is_none_after_an_in_block_resignation() { let mut ctx = ctx_with_committee(BTreeMap::from([(cred(1), cc_member(None))])); ctx.resign(cred(1), None).unwrap(); assert!(CommitteeSlice::lookup(&ctx, &cred(1)).is_none()); } + + enum InBlock { + Nothing, + Authorize(u8), + Resign, + } + + /// A vote identifies its member by hot credential, so the reverse direction has to agree with + /// `lookup` on everything the ongoing block changes. In particular, a rotation must retire the + /// previous hot credential rather than leave both live, and an authorization is reachable whether + /// or not its cold credential held a seat at block start. + #[test_case(Some(cc_member(Some(2))), InBlock::Nothing, 2 => 1 ; "authorized at block start")] + #[test_case(Some(cc_member(Some(2))), InBlock::Nothing, 3 => 0 ; "not authorized by anyone")] + #[test_case(Some(cc_member(None)), InBlock::Authorize(2), 2 => 1 ; "authorized by this block")] + #[test_case(Some(cc_member(Some(2))), InBlock::Authorize(3), 3 => 1 ; "rotated to in this block")] + #[test_case(Some(cc_member(Some(2))), InBlock::Authorize(3), 2 => 0 ; "rotated away from in this block")] + #[test_case(Some(cc_member(Some(2))), InBlock::Resign, 2 => 0 ; "resigned in this block")] + #[test_case(None, InBlock::Authorize(2), 2 => 1 ; "authorized by a cold credential holding no seat")] + #[test_case(None, InBlock::Nothing, 2 => 0 ; "no members at all")] + fn committee_lookup_by_hot_credential(member: Option, in_block: InBlock, queried: u8) -> usize { + let mut ctx = ctx_with_committee(member.into_iter().map(|member| (cred(1), member)).collect()); + + match in_block { + InBlock::Nothing => {} + InBlock::Authorize(hot) => ctx.delegate_cold_key(cred(1), cred(hot)).unwrap(), + InBlock::Resign => ctx.resign(cred(1), None).unwrap(), + } + + CommitteeSlice::lookup_by_hot_credential(&ctx, &cred(queried)).len() + } + + /// Nothing stops two seats from authorizing the same hot credential, and nothing resolves that + /// back to one of them, so both are returned. + #[test] + fn committee_lookup_by_hot_credential_returns_every_authorizing_member() { + let first = CCMember { hot_credential: Some(cred(9)), valid_until: Some(Epoch::from(10)) }; + let second = CCMember { hot_credential: Some(cred(9)), valid_until: Some(Epoch::from(20)) }; + let ctx = ctx_with_committee(BTreeMap::from([(cred(1), first.clone()), (cred(2), second.clone())])); + + assert_eq!(CommitteeSlice::lookup_by_hot_credential(&ctx, &cred(9)), BTreeSet::from([first, second])); + } } diff --git a/crates/amaru-ledger/src/governance/ratification/proposals_forest.rs b/crates/amaru-ledger/src/governance/ratification/proposals_forest.rs index 38284791bd..b8a521da18 100644 --- a/crates/amaru-ledger/src/governance/ratification/proposals_forest.rs +++ b/crates/amaru-ledger/src/governance/ratification/proposals_forest.rs @@ -1346,7 +1346,7 @@ mod tests { /// Make a simple proposal id based on a hash created from just one byte fn make_id(byte: u8) -> ProposalId { - ProposalId { transaction_id: Hash::new([byte; 32]), action_index: 0 } + ProposalId { transaction_id: Hash::new([byte; 32]), proposal_index: 0 } } /// Make a proposal with an optional parent diff --git a/crates/amaru-ledger/src/rules.rs b/crates/amaru-ledger/src/rules.rs index 0ef47d5398..d1bbe35a47 100644 --- a/crates/amaru-ledger/src/rules.rs +++ b/crates/amaru-ledger/src/rules.rs @@ -14,7 +14,9 @@ use std::{borrow::Cow, fmt, fmt::Display}; -use amaru_kernel::{Block, Certificate, GovernanceAction, Proposal, TransactionBody, parse_reward_account}; +use amaru_kernel::{ + Block, Certificate, GovernanceAction, HasOwnership, Proposal, TransactionBody, Voter, parse_reward_account, +}; use amaru_observability::debug_span; pub use block::execute as validate_block; @@ -79,15 +81,26 @@ fn prepare_withdrawals<'a>(context: &mut impl PreparationContext<'a>, transactio .for_each(|(account, _)| context.require_account(Cow::Owned(account))); } -/// Collect and require the proposals a transaction votes on. +/// Collect and require the proposals a transaction votes on, along with the state each voter's +/// existence is decided against. A `Voter` carries a bare hash rather than a credential, so the +/// credential is rebuilt here rather than borrowed from the transaction. fn prepare_votes<'a>(context: &mut impl PreparationContext<'a>, transaction: &'a TransactionBody) { let Some(votes) = transaction.votes.as_ref() else { return; }; - for (_, ballots) in votes.iter() { + + for (voter, ballots) in votes.iter() { for (proposal_id, _) in ballots.iter() { context.require_proposal(proposal_id); } + + match voter { + Voter::ConstitutionalCommitteeKey(_) | Voter::ConstitutionalCommitteeScript(_) => { + context.require_committee_voter(voter.owner()) + } + Voter::DRepKey(_) | Voter::DRepScript(_) => context.require_drep(Cow::Owned(voter.owner())), + Voter::StakePoolKey(pool) => context.require_pool(pool), + }; } } @@ -147,7 +160,7 @@ fn prepare_certificate<'a>(context: &mut impl PreparationContext<'a>, certificat Certificate::RegDRepCert(drep, _, _) | Certificate::UnRegDRepCert(drep, _) - | Certificate::UpdateDRepCert(drep, _) => context.require_drep(drep), + | Certificate::UpdateDRepCert(drep, _) => context.require_drep(Cow::Borrowed(drep)), Certificate::StakeRegistration(credential) | Certificate::Reg(credential, _) @@ -245,7 +258,7 @@ pub(crate) mod tests { /// A body carrying every kind of reference the preparation pass collects: spent, reference and /// collateral inputs, a withdrawal, one certificate per `require_*` target, a vote, and a /// treasury-withdrawal proposal whose return account must also be resolved. - const PREPARE_TRANSACTION_BODY: &str = "a900d9010281825820a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1000180021a00030d400dd9010281825820a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a30012d9010281825820a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a20005a1581de093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd81a000f4240048782008200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd883028200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8581c111111111111111111111111111111111111111111111111111111118304581c11111111111111111111111111111111111111111111111111111111183283098200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd88200581c22222222222222222222222222222222222222222222222222222222840a8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8581c111111111111111111111111111111111111111111111111111111118200581c22222222222222222222222222222222222222222222222222222222830e8200581c333333333333333333333333333333333333333333333333333333338200581c4444444444444444444444444444444444444444444444444444444484108200581c222222222222222222222222222222222222222222222222222222221a1dcd6500f613a18202581c22222222222222222222222222222222222222222222222222222222a1825820b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1008201f614d9010281841b000000174876e800581de093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd88302a1581de0222222222222222222222222222222222222222222222222222222221a006acfc0f6827368747470733a2f2f6578616d706c652e636f6d58200000000000000000000000000000000000000000000000000000000000000000"; + const PREPARE_TRANSACTION_BODY: &str = "a900d9010281825820a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1000180021a00030d400dd9010281825820a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a30012d9010281825820a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a20005a1581de093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd81a000f4240048782008200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd883028200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8581c111111111111111111111111111111111111111111111111111111118304581c11111111111111111111111111111111111111111111111111111111183283098200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd88200581c22222222222222222222222222222222222222222222222222222222840a8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8581c111111111111111111111111111111111111111111111111111111118200581c22222222222222222222222222222222222222222222222222222222830e8200581c333333333333333333333333333333333333333333333333333333338200581c4444444444444444444444444444444444444444444444444444444484108200581c222222222222222222222222222222222222222222222222222222221a1dcd6500f613a38202581c22222222222222222222222222222222222222222222222222222222a1825820b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1008201f68200581c55555555555555555555555555555555555555555555555555555555a1825820b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1008201f68204581c66666666666666666666666666666666666666666666666666666666a1825820b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1008201f614d9010281841b000000174876e800581de093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd88302a1581de0222222222222222222222222222222222222222222222222222222221a006acfc0f6827368747470733a2f2f6578616d706c652e636f6d58200000000000000000000000000000000000000000000000000000000000000000"; #[test] fn prepare_transaction_requires_every_referenced_entity() { @@ -261,6 +274,8 @@ pub(crate) mod tests { let pool = Hash::from(hex::decode("11".repeat(28)).unwrap().as_slice()); let drep = Hash::from(hex::decode("22".repeat(28)).unwrap().as_slice()); let cc_cold = Hash::from(hex::decode("33".repeat(28)).unwrap().as_slice()); + let cc_hot = Hash::from(hex::decode("55".repeat(28)).unwrap().as_slice()); + let voting_pool = Hash::from(hex::decode("66".repeat(28)).unwrap().as_slice()); // spent, collateral and reference inputs all have to be resolvable assert_eq!( @@ -277,18 +292,23 @@ pub(crate) mod tests { // the withdrawal's reward account, plus the proposal's treasury-withdrawal target BTreeSet::from([StakeCredential::AddrKeyhash(proposal_key), StakeCredential::AddrKeyhash(dev_key),]) ); - assert_eq!(context.pools.into_iter().copied().collect::>(), BTreeSet::from([pool])); + // the certificates' pool, plus the one that cast a vote + assert_eq!(context.pools.into_iter().copied().collect::>(), BTreeSet::from([pool, voting_pool])); + // the certificates' DRep, which is also the one that cast a vote assert_eq!( - context.dreps.into_iter().cloned().collect::>(), + context.dreps.into_iter().map(|k| k.into_owned()).collect::>(), BTreeSet::from([StakeCredential::AddrKeyhash(drep)]) ); assert_eq!(context.drep_delegations.into_iter().cloned().collect::>(), vec![DRep::Key(drep)]); + // a certificate names a member by cold credential... assert_eq!( context.committee.into_iter().cloned().collect::>(), BTreeSet::from([StakeCredential::AddrKeyhash(cc_cold)]) ); + // ...whereas a vote names one by the hot credential it authorized + assert_eq!(context.committee_voters, BTreeSet::from([StakeCredential::AddrKeyhash(cc_hot)])); - // the proposal the vote is cast on + // the single proposal all three votes are cast on assert_eq!(context.proposals.len(), 1); } diff --git a/crates/amaru-ledger/src/rules/transaction/phase_one/fixture.rs b/crates/amaru-ledger/src/rules/transaction/phase_one/fixture.rs index dc1d9f5196..5dabd2c54d 100644 --- a/crates/amaru-ledger/src/rules/transaction/phase_one/fixture.rs +++ b/crates/amaru-ledger/src/rules/transaction/phase_one/fixture.rs @@ -15,21 +15,21 @@ use std::collections::{BTreeMap, BTreeSet}; use amaru_kernel::{ - CertificatePointer, DRep, DRepRegistration, Epoch, EraHistoryProxy, GovernanceAction, Hash, Lovelace, - MemoizedTransactionOutput, NetworkName, PoolId, Pots, ProposalId, ProposalKind, ProposalsRoots, ProtocolParameters, - StakeCredential, TransactionInput, TransactionPointer, cbor, json, + CertificatePointer, DRep, DRepRegistration, Epoch, EraHistoryProxy, Lovelace, MemoizedTransactionOutput, + NetworkName, PoolId, Pots, ProposalId, ProposalKind, ProposalsRoots, ProtocolParameters, StakeCredential, + TransactionInput, TransactionPointer, cbor, json, utils::serde::{RefOrInline, deserialize_utxo, hex_to_bytes}, }; use serde::Deserialize; use crate::{ - context::{AccountState, DelegateError}, + context::{AccountState, CCMember, DelegateError}, epoch_transition::GovernanceActivity, rules::{ WithPosition, transaction::phase_one::{ InvalidCertificates, InvalidCollateral, InvalidFees, InvalidInputs, InvalidTransactionMetadata, - InvalidVKeyWitness, InvalidValidityInterval, InvalidWithdrawals, PhaseOneError, + InvalidVKeyWitness, InvalidValidityInterval, InvalidVotingProcedures, InvalidWithdrawals, PhaseOneError, outputs::{InvalidOutput, InvalidOutputs}, proposals::InvalidProposals, }, @@ -60,9 +60,11 @@ pub(super) struct InitialState { pub(super) accounts: BTreeMap, #[serde(deserialize_with = "deserialize_dreps", default)] pub(super) dreps: BTreeMap, + #[serde(deserialize_with = "deserialize_committee", default)] + pub(super) committee: BTreeMap, #[serde(deserialize_with = "deserialize_proposals", default)] pub(super) proposals: BTreeMap, - #[serde(deserialize_with = "deserialize_proposals_roots", default)] + #[serde(default)] pub(super) proposals_roots: ProposalsRoots, #[serde(default)] pub(super) governance_activity: GovernanceActivity, @@ -80,6 +82,20 @@ where cbor::decode(&bytes).map_err(serde::de::Error::custom) } +fn deserialize_optional_cbor_hex<'de, T, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: for<'b> cbor::Decode<'b, ()>, +{ + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(hex) => { + let bytes = hex::decode(hex).map_err(serde::de::Error::custom)?; + cbor::decode(&bytes).map(Some).map_err(serde::de::Error::custom) + } + } +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PoolDelegationProxy { @@ -156,55 +172,40 @@ where .collect()) } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ProposalIdProxy { - transaction_id: Hash<32>, - proposal_index: u32, -} - -impl From for ProposalId { - fn from(proxy: ProposalIdProxy) -> Self { - ProposalId { transaction_id: proxy.transaction_id, action_index: proxy.proposal_index } - } -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct ProposalProxy { - id: ProposalIdProxy, - #[serde(deserialize_with = "deserialize_cbor_hex")] - gov_action: GovernanceAction, -} - fn deserialize_proposals<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { - let entries = Vec::::deserialize(deserializer)?; - Ok(entries.into_iter().map(|entry| (ProposalId::from(entry.id), ProposalKind::from(&entry.gov_action))).collect()) + let entries = Vec::<(ProposalId, ProposalKind)>::deserialize(deserializer)?; + Ok(entries.into_iter().collect()) } -#[derive(Deserialize, Default)] -#[serde(rename_all = "camelCase", default)] -struct ProposalsRootsProxy { - protocol_parameters: Option, - hard_fork: Option, - constitutional_committee: Option, - constitution: Option, +/// A row of the constitutional committee, keyed by cold credential. `hotCredential` is absent for a +/// member that has never authorized one or has resigned; `validUntil` is absent for a member that is +/// not (or no longer) elected, which is a state a member can still authorize a hot key from. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CommitteeMemberProxy { + #[serde(deserialize_with = "deserialize_cbor_hex")] + cold_credential: StakeCredential, + #[serde(default, deserialize_with = "deserialize_optional_cbor_hex")] + hot_credential: Option, + #[serde(default)] + valid_until: Option, } -fn deserialize_proposals_roots<'de, D>(deserializer: D) -> Result +fn deserialize_committee<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { - let proxy = ProposalsRootsProxy::deserialize(deserializer)?; - Ok(ProposalsRoots { - protocol_parameters: proxy.protocol_parameters.map(ProposalId::from), - hard_fork: proxy.hard_fork.map(ProposalId::from), - constitutional_committee: proxy.constitutional_committee.map(ProposalId::from), - constitution: proxy.constitution.map(ProposalId::from), - }) + let entries = Vec::::deserialize(deserializer)?; + Ok(entries + .into_iter() + .map(|entry| { + let member = CCMember { hot_credential: entry.hot_credential, valid_until: entry.valid_until }; + (entry.cold_credential, member) + }) + .collect()) } pub(super) enum Expected { @@ -269,6 +270,7 @@ pub(super) enum Predicate { StakePoolNotRegisteredOnKeyPOOL, StakePoolCostTooLowPOOL, ValueNotConservedUTxO, + VotersDoNotExist, WithdrawalsNotInRewardsCERTS, WrongNetworkInTxBody, WrongNetworkInTxOutput, @@ -337,6 +339,9 @@ impl From for Predicate { PhaseOneError::Proposals(InvalidProposals::InvalidPrevGovActionId { .. }) => { Predicate::InvalidPrevGovActionId } + PhaseOneError::VotingProcedures(InvalidVotingProcedures::VotersDoNotExist(_)) => { + Predicate::VotersDoNotExist + } PhaseOneError::ValueNotPreserved(_) => Predicate::ValueNotConservedUTxO, PhaseOneError::Certificates(InvalidCertificates::StakeCredentialInvalidPoolDelegation(ref e)) => match e { DelegateError::UnknownSource(_) => Predicate::StakeCredentialInvalidPoolDelegation, diff --git a/crates/amaru-ledger/src/rules/transaction/phase_one/mod.rs b/crates/amaru-ledger/src/rules/transaction/phase_one/mod.rs index 37f5cb12ba..939c68fe00 100644 --- a/crates/amaru-ledger/src/rules/transaction/phase_one/mod.rs +++ b/crates/amaru-ledger/src/rules/transaction/phase_one/mod.rs @@ -51,6 +51,7 @@ pub mod vkey_witness; pub use vkey_witness::InvalidVKeyWitness; pub mod voting_procedures; +pub use voting_procedures::InvalidVotingProcedures; pub mod withdrawals; pub use withdrawals::InvalidWithdrawals; @@ -97,6 +98,9 @@ pub enum PhaseOneError { #[error("invalid proposals: {0}")] Proposals(#[from] proposals::InvalidProposals), + #[error("invalid voting procedures: {0}")] + VotingProcedures(#[from] InvalidVotingProcedures), + #[error("invalid transaction metadata: {0}")] Metadata(#[from] InvalidTransactionMetadata), @@ -283,7 +287,7 @@ where })?; debug_span!(ledger::rules::phase_one::VOTES) - .in_scope(|| voting_procedures::execute(context, mem::take(&mut transaction_body.votes))); + .in_scope(|| voting_procedures::execute(context, mem::take(&mut transaction_body.votes)))?; } else { debug_span!(ledger::rules::phase_one::CERTIFICATES).in_scope(|| { certificates::count_lovelace(context, protocol_parameters, mem::take(&mut transaction_body.certificates)) @@ -406,7 +410,7 @@ mod tests { fixture.initial_state.pools, fixture.initial_state.accounts, fixture.initial_state.dreps, - Default::default(), + fixture.initial_state.committee, fixture.initial_state.proposals, fixture.initial_state.proposals_roots, fixture.initial_state.pots.treasury, diff --git a/crates/amaru-ledger/src/rules/transaction/phase_one/proposals.rs b/crates/amaru-ledger/src/rules/transaction/phase_one/proposals.rs index 4f98b3d215..9f9670b67c 100644 --- a/crates/amaru-ledger/src/rules/transaction/phase_one/proposals.rs +++ b/crates/amaru-ledger/src/rules/transaction/phase_one/proposals.rs @@ -91,7 +91,7 @@ where context.produce_lovelace(proposal.deposit); let pointer = ProposalPointer { transaction: transaction.1, proposal_index }; - let id = ProposalId { transaction_id: *transaction.0, action_index: proposal_index as u32 }; + let id = ProposalId { transaction_id: *transaction.0, proposal_index: proposal_index as u32 }; context.acknowledge(id, pointer, proposal) } diff --git a/crates/amaru-ledger/src/rules/transaction/phase_one/voting_procedures.rs b/crates/amaru-ledger/src/rules/transaction/phase_one/voting_procedures.rs index 30f37a191a..69022ed535 100644 --- a/crates/amaru-ledger/src/rules/transaction/phase_one/voting_procedures.rs +++ b/crates/amaru-ledger/src/rules/transaction/phase_one/voting_procedures.rs @@ -12,40 +12,73 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use amaru_kernel::{ HasOwnership, MemoizedDatum, NonEmptyKeyValuePairs, ProposalId, RedeemerTag, RequiredScript, StakeCredential, Voter, VotingProcedure, }; +use thiserror::Error; -use crate::context::{ProposalsSlice, WitnessSlice}; +use crate::context::{CommitteeSlice, DRepsSlice, PoolsSlice, ProposalsSlice, WitnessSlice}; + +#[derive(Debug, Error)] +pub enum InvalidVotingProcedures { + #[error("voters do not exist: {0:?}")] + VotersDoNotExist(BTreeSet), +} pub(crate) fn execute( context: &mut C, voting_procedures: Option>>, -) where - C: WitnessSlice + ProposalsSlice, +) -> Result<(), InvalidVotingProcedures> +where + C: WitnessSlice + ProposalsSlice + CommitteeSlice + DRepsSlice + PoolsSlice, { if let Some(voting_procedures) = voting_procedures { - voting_procedures.into_iter().collect::>().into_iter().enumerate().for_each( - |(index, (voter, votes))| { - match voter.owner() { - StakeCredential::ScriptHash(hash) => { - context.require_script_witness(RequiredScript { - hash, - index: index as u32, - purpose: RedeemerTag::Vote, - datum: MemoizedDatum::None, - }); - } - StakeCredential::AddrKeyhash(hash) => context.require_vkey_witness(hash), + let voting_procedures = voting_procedures.into_iter().collect::>(); + + let unknown_voters = + voting_procedures.keys().filter(|voter| !exists(context, voter)).cloned().collect::>(); + + if !unknown_voters.is_empty() { + return Err(InvalidVotingProcedures::VotersDoNotExist(unknown_voters)); + } + + voting_procedures.into_iter().enumerate().for_each(|(index, (voter, votes))| { + match voter.owner() { + StakeCredential::ScriptHash(hash) => { + context.require_script_witness(RequiredScript { + hash, + index: index as u32, + purpose: RedeemerTag::Vote, + datum: MemoizedDatum::None, + }); } + StakeCredential::AddrKeyhash(hash) => context.require_vkey_witness(hash), + } - votes.into_iter().for_each(|(proposal_id, ballot)| { - context.vote(proposal_id, voter.clone(), ballot.vote, ballot.anchor); - }) - }, - ); + votes.into_iter().for_each(|(proposal_id, ballot)| { + context.vote(proposal_id, voter.clone(), ballot.vote, ballot.anchor); + }) + }); + } + + Ok(()) +} + +/// Whether the entity a vote is cast by is known at this point in the block. +fn exists(context: &C, voter: &Voter) -> bool +where + C: CommitteeSlice + DRepsSlice + PoolsSlice, +{ + match voter { + // A vote identifies its member by the hot credential the member authorized, never by the cold + // credential that identifies the seat. + Voter::ConstitutionalCommitteeKey(_) | Voter::ConstitutionalCommitteeScript(_) => { + !CommitteeSlice::lookup_by_hot_credential(context, &voter.owner()).is_empty() + } + Voter::DRepKey(_) | Voter::DRepScript(_) => DRepsSlice::lookup(context, &voter.owner()).is_some(), + Voter::StakePoolKey(pool) => PoolsSlice::exists(context, *pool), } } diff --git a/crates/amaru-ledger/src/snapshot.rs b/crates/amaru-ledger/src/snapshot.rs index 260e39ce72..4a968e22c4 100644 --- a/crates/amaru-ledger/src/snapshot.rs +++ b/crates/amaru-ledger/src/snapshot.rs @@ -85,7 +85,7 @@ pub fn proposal_state( let proposed_in_pointer = ProposalPointer { transaction: TransactionPointer { slot: era_history.epoch_bounds(proposed_in)?.start, transaction_index: 0 }, - proposal_index: id.action_index as usize, + proposal_index: id.proposal_index as usize, }; Ok(( diff --git a/crates/amaru-ledger/src/state/volatile.rs b/crates/amaru-ledger/src/state/volatile.rs index 8465dae185..0a86f30c71 100644 --- a/crates/amaru-ledger/src/state/volatile.rs +++ b/crates/amaru-ledger/src/state/volatile.rs @@ -99,6 +99,12 @@ pub trait VolatileState { Self: 'a; fn resolve_cc_member<'a>(&'a self, credential: &StakeCredential) -> Self::CCMember<'a>; + /// Cold credentials whose hot-key delegation was touched inside this window. Membership is never + /// established by a block, so this does not enumerate members; together with the stable store's + /// rows it bounds the members that can currently authorize a hot credential. Needed because a + /// vote names its member by hot credential, which is not a key any layer is indexed by. + fn touched_cc_members(&self) -> impl Iterator; + // ----------------------------------------------------------------------------------- Proposals type Proposal; fn resolve_proposal(&self, proposal_id: &ProposalId) -> Self::Proposal; diff --git a/crates/amaru-ledger/src/state/volatile/aggregate.rs b/crates/amaru-ledger/src/state/volatile/aggregate.rs index 914a1f8c22..aa467edf9e 100644 --- a/crates/amaru-ledger/src/state/volatile/aggregate.rs +++ b/crates/amaru-ledger/src/state/volatile/aggregate.rs @@ -126,6 +126,13 @@ impl VolatileAggregate { } } + /// Every cold credential whose hot-key delegation this aggregate recorded a verdict for. Since no + /// fragment establishes membership (see [`Self::resolve_cc_member`]), this is exactly the members + /// whose hot key was authorized or resigned inside the window; each still needs resolving. + pub fn cc_members(&self) -> impl Iterator { + self.committee.keys() + } + /// This aggregate's view of a governance proposal. Proposals are add-only in a block, so this is /// `Exists` or `Unknown`; pruning only happens at the boundary. pub fn resolve_proposal(&self, id: &ProposalId) -> Existence { diff --git a/crates/amaru-ledger/src/state/volatile/aggregate/indexed_set.rs b/crates/amaru-ledger/src/state/volatile/aggregate/indexed_set.rs index 528671628a..9d3e7d65d3 100644 --- a/crates/amaru-ledger/src/state/volatile/aggregate/indexed_set.rs +++ b/crates/amaru-ledger/src/state/volatile/aggregate/indexed_set.rs @@ -57,6 +57,12 @@ impl IndexedSet { } } + /// Every key this index holds a verdict for, tombstones included. A key's presence says only that + /// some recorded fragment touched it, so callers still need [`get`](Self::get) for its verdict. + pub fn keys(&self) -> impl Iterator { + self.index.keys() + } + #[cfg(test)] pub fn is_empty(&self) -> bool { self.index.is_empty() diff --git a/crates/amaru-ledger/src/state/volatile/db.rs b/crates/amaru-ledger/src/state/volatile/db.rs index 6128d297c0..f57d0ac2d8 100644 --- a/crates/amaru-ledger/src/state/volatile/db.rs +++ b/crates/amaru-ledger/src/state/volatile/db.rs @@ -148,6 +148,14 @@ impl VolatileState for VolatileDB { ) } + /// The overlay is not a source here: it only carries boundary membership changes, never a hot-key + /// authorization. An authorization only ever arrives through a block, so it is in one of the two + /// series or has already been flushed to the stable store -- including one declared by a member + /// that is not (yet) elected, which the store keeps as a row with a hot key and no term. + fn touched_cc_members(&self) -> impl Iterator { + self.current.touched_cc_members().chain(self.draining.touched_cc_members()) + } + // ----------------------------------------------------------------------------------- Proposals type Proposal = Existence; /// Resolve a governance proposal across the volatile layers, precedence `current -> overlay diff --git a/crates/amaru-ledger/src/state/volatile/series.rs b/crates/amaru-ledger/src/state/volatile/series.rs index 41c68f8ec0..75ff922de0 100644 --- a/crates/amaru-ledger/src/state/volatile/series.rs +++ b/crates/amaru-ledger/src/state/volatile/series.rs @@ -70,6 +70,10 @@ impl VolatileState for VolatileSeries { self.aggregate.resolve_cc_member(credential) } + fn touched_cc_members(&self) -> impl Iterator { + self.aggregate.cc_members() + } + // ----------------------------------------------------------------------------------- Proposals type Proposal = Existence; fn resolve_proposal(&self, id: &ProposalId) -> Existence { diff --git a/crates/amaru-ledger/tests/data/phase-one/README.md b/crates/amaru-ledger/tests/data/phase-one/README.md index 5bf32fde2c..ef8e20d367 100644 --- a/crates/amaru-ledger/tests/data/phase-one/README.md +++ b/crates/amaru-ledger/tests/data/phase-one/README.md @@ -45,9 +45,8 @@ UTxO entries are pairs of hex-encoded CBOR: `input` is `TransactionInput`, ### Initial state -Initial state represents the state on which the transaction is validated. Every -field below is required and must be present in each fixture, even when its value -is empty: +Initial state represents the state on which the transaction is validated. It +may include any of the following objects: - `utxo`: array of `{ input, output }` entries, each hex-encoded CBOR. Empty when no UTxO state is seeded. @@ -60,18 +59,24 @@ is empty: - `dreps`: `[{ credential, deposit, registeredAt, validUntil }]`, with the same `credential` encoding, a `registeredAt` certificate pointer, and a `validUntil` epoch. +- `committee`: `[{ coldCredential, hotCredential?, validUntil? }]`, the constitutional + committee keyed by cold credential, both credentials hex-encoded CBOR of a + `StakeCredential`. `hotCredential` is absent for a member that has never authorized + one or has resigned; `validUntil` is absent for a member holding no term, which is + still a state a member can authorize a hot credential from. Note that a vote + identifies its committee member by *hot* credential. - `proposals`: `[{ id, govAction }]`, the in-flight governance proposals seeding the block-start proposal set. `id` is a governance action id (see below) and `govAction` is hex-encoded CBOR of a `GovAction`. Use `[]` when none are seeded. - `proposalsRoots`: `{ protocolParameters?, hardFork?, constitutionalCommittee?, constitution? }`, the latest enacted governance action id per purpose, each a governance action id (see below). Use `{}` when none are enacted; absent purposes default to none. +- `governanceActivity`: `{ consecutiveDormantEpochs }`. A governance action id is `{ transactionId, proposalIndex }`, mirroring `CertificatePointer`: `transactionId` is the hex-encoded 32-byte hash of the transaction that submitted the action, and `proposalIndex` is the action's index within that transaction. -- `governanceActivity`: `{ consecutiveDormantEpochs }`. `protocolParameters` is loosely inspired by [Ogmios](https://github.com/CardanoSolutions/ogmios) but intentionally diverges: diff --git a/crates/amaru-ledger/tests/data/phase-one/fail/InvalidPrevGovActionId/1.json b/crates/amaru-ledger/tests/data/phase-one/fail/InvalidPrevGovActionId/1.json index 78a52c7c21..cace72de51 100644 --- a/crates/amaru-ledger/tests/data/phase-one/fail/InvalidPrevGovActionId/1.json +++ b/crates/amaru-ledger/tests/data/phase-one/fail/InvalidPrevGovActionId/1.json @@ -15,22 +15,15 @@ "output": "bf00581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011b0000001748c33340ff" } ], - "pools": [], - "accounts": [], - "dreps": [], - "proposals": [ + "accounts": [ { - "id": { - "transactionId": "a829a847676b9e2c0ddd470708741dcff03b1769b4dc5ee8632be1817e892ee3", - "proposalIndex": 0 - }, - "govAction": "8305f682827368747470733a2f2f6578616d706c652e636f6d58200000000000000000000000000000000000000000000000000000000000000000f6" + "credential": "8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8", + "deposit": 2000000 } ], - "proposalsRoots": {}, - "governanceActivity": { - "consecutiveDormantEpochs": 0 - } + "proposals": [ + [{ "transactionId": "a829a847676b9e2c0ddd470708741dcff03b1769b4dc5ee8632be1817e892ee3", "proposalIndex": 0 }, "Constitution"] + ] }, "point": { "slot": 0, diff --git a/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/0.json b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/0.json new file mode 100644 index 0000000000..4068e0898c --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/0.json @@ -0,0 +1,30 @@ +{ + "title": "vote cast by a drep key credential that is not registered", + "description": "A DRep voter whose key credential has no registration in the dreps state.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "825820e9dbbc7adabc18be00c69527d6dcce23cf59c15ddc362d777085484710040df800", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d9010281825820e9dbbc7adabc18be00c69527d6dcce23cf59c15ddc362d777085484710040df8000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18202581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db125840d554193b177806ce224ee644f7a3ae9350f8da34c4d02a95210221922d05ab5c5701e4b58839b166b6c52471870a9aa4d07380cce4bce114339d9a58947aca00f5f6", + "expected": { + "predicate": "VotersDoNotExist" + } +} diff --git a/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/1.json b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/1.json new file mode 100644 index 0000000000..863eb3ee17 --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/1.json @@ -0,0 +1,30 @@ +{ + "title": "vote cast by a drep script credential that is not registered", + "description": "A DRep voter whose script credential has no registration in the dreps state, witnessed by the matching native script so only the existence check is left to fail.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "825820771608c4a543f03beaffe14ade324b7d8bec943471c7097122cc7dc230a7cf1300", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d9010281825820771608c4a543f03beaffe14ade324b7d8bec943471c7097122cc7dc230a7cf13000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18203581cd441227553a0f1a965fee7d60a0f724b368dd1bddbc208730fccebcfa18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a200d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db125840212600a19b598b6b650e5ee74ddcfae39447fd82f21adc99362a135c16535fc4c791de4d322316b2c5d8e68b18b12292ff5e68416696e6dd783c8d57b872a00d0181820180f5f6", + "expected": { + "predicate": "VotersDoNotExist" + } +} diff --git a/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/2.json b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/2.json new file mode 100644 index 0000000000..99576da033 --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/2.json @@ -0,0 +1,36 @@ +{ + "title": "vote cast by a credential registered as a stake account but not as a drep", + "description": "A DRep voter whose credential holds a registered stake account; existence is resolved against the dreps state, not the accounts state.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "825820de59b21a33d34d429ce7effba154643ecb9c50b29fe02407708072046de59f5200", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "accounts": [ + { + "credential": "8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8", + "deposit": 2000000 + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d9010281825820de59b21a33d34d429ce7effba154643ecb9c50b29fe02407708072046de59f52000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18202581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12584012011435cb07fed8a592b9f5e4df9360259533fd3ca5d7bdf54986aa1bf706a0b9b80da05bb84daf2da7b1d9fc0d0608cb9a3f6ad462d2de2778f2f83de9e604f5f6", + "expected": { + "predicate": "VotersDoNotExist" + } +} diff --git a/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/3.json b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/3.json new file mode 100644 index 0000000000..05dd5251fc --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/3.json @@ -0,0 +1,30 @@ +{ + "title": "vote cast by a committee hot key with an empty committee", + "description": "A constitutional committee voter with no members at all in the committee state.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "825820c14fc165f73be892fa7f9be6ea80949c2ac86fe38ff70dbb8f41de266b8c21ce00", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d9010281825820c14fc165f73be892fa7f9be6ea80949c2ac86fe38ff70dbb8f41de266b8c21ce000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db125840f15048fe89e5c3ad5c1a5af9d5149d4d5b5ec03952c956dfe637db88bfe443af0cc6382ac9cccfd465e8bea685c714735703b81f3e34576b9813bde88dce8d0df5f6", + "expected": { + "predicate": "VotersDoNotExist" + } +} diff --git a/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/4.json b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/4.json new file mode 100644 index 0000000000..4fa62eb417 --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/4.json @@ -0,0 +1,37 @@ +{ + "title": "vote cast by a committee hot key the member has rotated away from", + "description": "A member authorizing a different hot key than the one the vote names; only the currently authorized credential counts.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "825820ac965713b19f1e18f5f28cec07418a761df76f559f868087e64309c6cbcf688e00", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "committee": [ + { + "coldCredential": "8200581cecc3093b061a9ed426cd9956161fc48d4ed1ac58dc4aaed7e1ce1830", + "hotCredential": "8200581c2e06285720a42375de8c91a3f9f27d89516f93a2f60ad638ef30eea6", + "validUntil": 200 + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d9010281825820ac965713b19f1e18f5f28cec07418a761df76f559f868087e64309c6cbcf688e000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12584027eaa8e8aa7a00f4c2284f4d64923a87075134be44450e07b6d9afbfadb34ab92a90a7b21586b0e1d8fd4d64fd573c4e98188a883e12aae8630b382a998d2b05f5f6", + "expected": { + "predicate": "VotersDoNotExist" + } +} diff --git a/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/5.json b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/5.json new file mode 100644 index 0000000000..236d0e16df --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/5.json @@ -0,0 +1,37 @@ +{ + "title": "vote cast by a committee hot key of a member that authorized none", + "description": "An in-term member carrying no hot credential, the state a resignation leaves behind; such a member authorizes nothing and so contributes no voter.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "8258200e0a86a3c244186b96fdfd9c865d4fd623daa8f9fcd82b2ff74d20c3f0943ae700", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "committee": [ + { + "coldCredential": "8200581cecc3093b061a9ed426cd9956161fc48d4ed1ac58dc4aaed7e1ce1830", + "hotCredential": null, + "validUntil": 200 + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d90102818258200e0a86a3c244186b96fdfd9c865d4fd623daa8f9fcd82b2ff74d20c3f0943ae7000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db125840af1285001fdc519fedc2de9d044de70ecfbc7413b58478d79c48f2fee8d8c22bb93ae0ee77d3babef3748922d35bbfdb1e516a04152aaa0e057acb629fa9d506f5f6", + "expected": { + "predicate": "VotersDoNotExist" + } +} diff --git a/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/6.json b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/6.json new file mode 100644 index 0000000000..e9d0a57414 --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/6.json @@ -0,0 +1,37 @@ +{ + "title": "vote cast by a committee member's cold credential instead of its hot one", + "description": "A member whose cold credential is the one the vote names while its authorized hot credential is another; a committee voter is identified by hot credential only.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "8258200356085dce341cd7ccf34436c490097520cf2da58a394d9fbaab9b8e2487950c00", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "committee": [ + { + "coldCredential": "8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8", + "hotCredential": "8200581c2e06285720a42375de8c91a3f9f27d89516f93a2f60ad638ef30eea6", + "validUntil": 200 + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d90102818258200356085dce341cd7ccf34436c490097520cf2da58a394d9fbaab9b8e2487950c000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12584047d4120a77c6217e2143ee7dc2964e0bc176433948a3042ba542f985f78c19cf64f2efdab6f49d5eeab76da32ad8cf5869ab8e829d9b8bb1edb4f3b7f2d4810ef5f6", + "expected": { + "predicate": "VotersDoNotExist" + } +} diff --git a/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/7.json b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/7.json new file mode 100644 index 0000000000..d937b7ec55 --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/7.json @@ -0,0 +1,37 @@ +{ + "title": "vote cast by a committee hot script credential that no member authorized", + "description": "A script hot credential absent from the committee state, witnessed by the matching native script so only the existence check is left to fail.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "825820baabcf1b7cedd47b9a0c8e778c19d2077dbca66c6001fde523fedec90439366800", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "committee": [ + { + "coldCredential": "8200581cecc3093b061a9ed426cd9956161fc48d4ed1ac58dc4aaed7e1ce1830", + "hotCredential": "8200581c2e06285720a42375de8c91a3f9f27d89516f93a2f60ad638ef30eea6", + "validUntil": 200 + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d9010281825820baabcf1b7cedd47b9a0c8e778c19d2077dbca66c6001fde523fedec904393668000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18201581cd441227553a0f1a965fee7d60a0f724b368dd1bddbc208730fccebcfa18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a200d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db125840e854c5b7fad2dbe71875b514adbd9c2714a94b423b947f69088c40f404d649fd66773da30a2d7259c31995116bff14fd58feb50ed43dc33e8c1b9ded8d914b040181820180f5f6", + "expected": { + "predicate": "VotersDoNotExist" + } +} diff --git a/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/8.json b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/8.json new file mode 100644 index 0000000000..8e84b5c207 --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/fail/VotersDoNotExist/8.json @@ -0,0 +1,30 @@ +{ + "title": "vote cast by a stake pool that is not registered", + "description": "A stake pool voter whose pool id is absent from the pools state.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "82582024a477f839ea0dca2feffa4d269ed7ca73e95831d90e686a456a1354e7ad316000", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d901028182582024a477f839ea0dca2feffa4d269ed7ca73e95831d90e686a456a1354e7ad3160000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18204581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db1258401b2ebef17dfe33be43867ece7e1814835ccf7b6ec613b3e2bbf15c3a6460ad9be10260f67bd48322a96a4ceb9f55f78e9635929c4caeece7e3427ac891e3160bf5f6", + "expected": { + "predicate": "VotersDoNotExist" + } +} diff --git a/crates/amaru-ledger/tests/data/phase-one/pass/gov-hardfork-initiation-follows-in-flight.json b/crates/amaru-ledger/tests/data/phase-one/pass/gov-hardfork-initiation-follows-in-flight.json index c743d3b1a4..9fa47c438f 100644 --- a/crates/amaru-ledger/tests/data/phase-one/pass/gov-hardfork-initiation-follows-in-flight.json +++ b/crates/amaru-ledger/tests/data/phase-one/pass/gov-hardfork-initiation-follows-in-flight.json @@ -15,27 +15,15 @@ "output": "bf00581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011b0000001748c64080ff" } ], - "pools": [], "accounts": [ { "credential": "8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8", "deposit": 2000000 } ], - "dreps": [], "proposals": [ - { - "id": { - "transactionId": "a829a847676b9e2c0ddd470708741dcff03b1769b4dc5ee8632be1817e892ee3", - "proposalIndex": 0 - }, - "govAction": "8301f6820a01" - } - ], - "proposalsRoots": {}, - "governanceActivity": { - "consecutiveDormantEpochs": 0 - } + [{ "transactionId": "a829a847676b9e2c0ddd470708741dcff03b1769b4dc5ee8632be1817e892ee3", "proposalIndex": 0 }, "HardFork"] + ] }, "point": { "slot": 100, diff --git a/crates/amaru-ledger/tests/data/phase-one/pass/gov-new-constitution-follows-in-flight.json b/crates/amaru-ledger/tests/data/phase-one/pass/gov-new-constitution-follows-in-flight.json index fcf21c2c33..37b90834be 100644 --- a/crates/amaru-ledger/tests/data/phase-one/pass/gov-new-constitution-follows-in-flight.json +++ b/crates/amaru-ledger/tests/data/phase-one/pass/gov-new-constitution-follows-in-flight.json @@ -22,13 +22,7 @@ } ], "proposals": [ - { - "id": { - "transactionId": "a829a847676b9e2c0ddd470708741dcff03b1769b4dc5ee8632be1817e892ee3", - "proposalIndex": 0 - }, - "govAction": "8305f682827368747470733a2f2f6578616d706c652e636f6d58200000000000000000000000000000000000000000000000000000000000000000f6" - } + [{ "transactionId": "a829a847676b9e2c0ddd470708741dcff03b1769b4dc5ee8632be1817e892ee3", "proposalIndex": 0 }, "Constitution"] ] }, "point": { diff --git a/crates/amaru-ledger/tests/data/phase-one/pass/gov-parameter-change-follows-in-flight.json b/crates/amaru-ledger/tests/data/phase-one/pass/gov-parameter-change-follows-in-flight.json index a256406809..102ac759dc 100644 --- a/crates/amaru-ledger/tests/data/phase-one/pass/gov-parameter-change-follows-in-flight.json +++ b/crates/amaru-ledger/tests/data/phase-one/pass/gov-parameter-change-follows-in-flight.json @@ -22,13 +22,7 @@ } ], "proposals": [ - { - "id": { - "transactionId": "a829a847676b9e2c0ddd470708741dcff03b1769b4dc5ee8632be1817e892ee3", - "proposalIndex": 0 - }, - "govAction": "8400f6a100182cf6" - } + [{ "transactionId": "a829a847676b9e2c0ddd470708741dcff03b1769b4dc5ee8632be1817e892ee3", "proposalIndex": 0 }, "ProtocolParameters"] ] }, "point": { diff --git a/crates/amaru-ledger/tests/data/phase-one/pass/gov-update-committee-follows-in-flight.json b/crates/amaru-ledger/tests/data/phase-one/pass/gov-update-committee-follows-in-flight.json index 424375ea08..a29f63b5c7 100644 --- a/crates/amaru-ledger/tests/data/phase-one/pass/gov-update-committee-follows-in-flight.json +++ b/crates/amaru-ledger/tests/data/phase-one/pass/gov-update-committee-follows-in-flight.json @@ -22,13 +22,7 @@ } ], "proposals": [ - { - "id": { - "transactionId": "a829a847676b9e2c0ddd470708741dcff03b1769b4dc5ee8632be1817e892ee3", - "proposalIndex": 0 - }, - "govAction": "8504f6d9010280a18200581c111111111111111111111111111111111111111111111111111111111a000186a0d81e820102" - } + [{ "transactionId": "a829a847676b9e2c0ddd470708741dcff03b1769b4dc5ee8632be1817e892ee3", "proposalIndex": 0 }, "ConstitutionalCommittee"] ] }, "point": { diff --git a/crates/amaru-ledger/tests/data/phase-one/pass/vote-committee-hot-key.json b/crates/amaru-ledger/tests/data/phase-one/pass/vote-committee-hot-key.json new file mode 100644 index 0000000000..1c6ce8831e --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/pass/vote-committee-hot-key.json @@ -0,0 +1,35 @@ +{ + "title": "vote cast by an authorized committee hot key", + "description": "A constitutional committee voter naming the hot key an elected member has authorized.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "825820c805346fe5bc524a57d0434e0c0596d8e703655bd1802b528e2d43032f991ec800", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "committee": [ + { + "coldCredential": "8200581cecc3093b061a9ed426cd9956161fc48d4ed1ac58dc4aaed7e1ce1830", + "hotCredential": "8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8", + "validUntil": 200 + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d9010281825820c805346fe5bc524a57d0434e0c0596d8e703655bd1802b528e2d43032f991ec8000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db12584099d68d82522be6276bdf13e8c709e12518dffacb5090c45d3f29caa9a3739aef9e0f5e8b521d577798bacc06098c6802bd6bb40a45b01594a5b87416b2182705f5f6", + "expected": "Pass" +} diff --git a/crates/amaru-ledger/tests/data/phase-one/pass/vote-committee-hot-script.json b/crates/amaru-ledger/tests/data/phase-one/pass/vote-committee-hot-script.json new file mode 100644 index 0000000000..d80f729135 --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/pass/vote-committee-hot-script.json @@ -0,0 +1,35 @@ +{ + "title": "vote cast by an authorized committee hot script credential", + "description": "A constitutional committee voter naming a script hot credential, witnessed by the matching native script.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "825820b9488a2acb2a4de446769c8e4b2aebf0045e79ee0f7ca36473f94113c34918bf00", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "committee": [ + { + "coldCredential": "8200581cecc3093b061a9ed426cd9956161fc48d4ed1ac58dc4aaed7e1ce1830", + "hotCredential": "8201581cd441227553a0f1a965fee7d60a0f724b368dd1bddbc208730fccebcf", + "validUntil": 200 + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d9010281825820b9488a2acb2a4de446769c8e4b2aebf0045e79ee0f7ca36473f94113c34918bf000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18201581cd441227553a0f1a965fee7d60a0f724b368dd1bddbc208730fccebcfa18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a200d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db125840920b2ee06e8fdbebd15ddfa5c34d00b8197bc3b0ecd2f99d0dff19d9b8093e4de816b5f007239fe7ef4593a9414771bf0b26160cb255fd8e0025917f4b49bc060181820180f5f6", + "expected": "Pass" +} diff --git a/crates/amaru-ledger/tests/data/phase-one/pass/vote-committee-unelected-member.json b/crates/amaru-ledger/tests/data/phase-one/pass/vote-committee-unelected-member.json new file mode 100644 index 0000000000..b0d544eeb1 --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/pass/vote-committee-unelected-member.json @@ -0,0 +1,35 @@ +{ + "title": "vote cast by an unelected committee member with an authorized hot key", + "description": "A member holding no term but an authorized hot key; existence applies no elected filter, so under protocol version 10 the vote stands and only UnelectedCommitteeVoters would reject it.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "825820e4ab7bd9de7060f2f81fc907132ce2942081ac10b2b9454d53642eb304a64a3800", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "committee": [ + { + "coldCredential": "8200581cecc3093b061a9ed426cd9956161fc48d4ed1ac58dc4aaed7e1ce1830", + "hotCredential": "8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8", + "validUntil": null + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d9010281825820e4ab7bd9de7060f2f81fc907132ce2942081ac10b2b9454d53642eb304a64a38000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db125840f46d6100d906cb1424b6f9234fd187090bddf85d0453f8f09700cbcdf7491556c325dac760dc35efd1dc234e532e357d413aa35a99c1ba2310ed73caa9306008f5f6", + "expected": "Pass" +} diff --git a/crates/amaru-ledger/tests/data/phase-one/pass/vote-drep-expired-registration.json b/crates/amaru-ledger/tests/data/phase-one/pass/vote-drep-expired-registration.json new file mode 100644 index 0000000000..388be13507 --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/pass/vote-drep-expired-registration.json @@ -0,0 +1,42 @@ +{ + "title": "vote cast by a drep whose registration has expired", + "description": "A DRep voter registered with a validUntil already behind the current epoch; existence ignores expiry, which only bears on voting power at ratification.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "82582079a1feff47319691aa09a630e592080973919453bf6ceac533146b154e1c775500", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "dreps": [ + { + "credential": "8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8", + "deposit": 500000000, + "registeredAt": { + "transaction": { + "slot": 0, + "transactionIndex": 0 + }, + "certificateIndex": 0 + }, + "validUntil": 0 + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d901028182582079a1feff47319691aa09a630e592080973919453bf6ceac533146b154e1c7755000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18202581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db125840b39ee87e82d42c626089d90dd90b5ca375ee72825237acc51aa7f2e8da1a4ef04cc4b042e5731d0f18d4b29279e45c2c73e8f03935c6d4647bdd4283b33e4607f5f6", + "expected": "Pass" +} diff --git a/crates/amaru-ledger/tests/data/phase-one/pass/vote-drep-key-credential.json b/crates/amaru-ledger/tests/data/phase-one/pass/vote-drep-key-credential.json new file mode 100644 index 0000000000..e5040a1b63 --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/pass/vote-drep-key-credential.json @@ -0,0 +1,42 @@ +{ + "title": "vote cast by a registered drep key credential", + "description": "A DRep voter whose credential is a registered key hash, which is the state voting_procedures::execute resolves a DRep voter against.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "825820af9946308273d128030690731934f53c5087b4bb5d34cade4a28dca2c00c1bc500", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "dreps": [ + { + "credential": "8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8", + "deposit": 500000000, + "registeredAt": { + "transaction": { + "slot": 0, + "transactionIndex": 0 + }, + "certificateIndex": 0 + }, + "validUntil": 1000 + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d9010281825820af9946308273d128030690731934f53c5087b4bb5d34cade4a28dca2c00c1bc5000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18202581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db1258409f86a7c61d7812bb78cc6cbe4e0d7328cd888d587fa2d2bc1df00f036a5fd2ed0f92429e3bfdfb9e06b942b4534490405b88d8f4a67cf734127fbd1d5f277900f5f6", + "expected": "Pass" +} diff --git a/crates/amaru-ledger/tests/data/phase-one/pass/vote-script-credential.json b/crates/amaru-ledger/tests/data/phase-one/pass/vote-script-credential.json index 327a3ab84c..ec2b5f516f 100644 --- a/crates/amaru-ledger/tests/data/phase-one/pass/vote-script-credential.json +++ b/crates/amaru-ledger/tests/data/phase-one/pass/vote-script-credential.json @@ -15,12 +15,23 @@ "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" } ], - "pools": [], - "accounts": [], - "dreps": [], - "governanceActivity": { - "consecutiveDormantEpochs": 0 - } + "dreps": [ + { + "credential": "8201581cd441227553a0f1a965fee7d60a0f724b368dd1bddbc208730fccebcf", + "deposit": 500000000, + "registeredAt": { + "transaction": { + "slot": 0, + "transactionIndex": 0 + }, + "certificateIndex": 0 + }, + "validUntil": 1000 + } + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] }, "point": { "slot": 0, diff --git a/crates/amaru-ledger/tests/data/phase-one/pass/vote-stake-pool.json b/crates/amaru-ledger/tests/data/phase-one/pass/vote-stake-pool.json new file mode 100644 index 0000000000..660e6c8e61 --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/pass/vote-stake-pool.json @@ -0,0 +1,31 @@ +{ + "title": "vote cast by a registered stake pool", + "description": "A stake pool voter whose pool id is registered, which is the state voting_procedures::execute resolves a pool voter against.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "825820064cad91f5304086de08f615c118a2dd29c21d1ea5a3ac538f6f962a709ed76e00", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "pools": [ + "93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8" + ], + "proposals": [ + [{ "transactionId": "7e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b", "proposalIndex": 0 }, "Orphan"] + ] + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d9010281825820064cad91f5304086de08f615c118a2dd29c21d1ea5a3ac538f6f962a709ed76e000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18204581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db125840e8f1bbee889d1e96b0587fb0a3a1db1a39b39950f74b5b43ec5adc3bd419c5031301cb5eb013efa327d7373837f1ed17a48d3aa70006c5ae9dd642f0adb06909f5f6", + "expected": "Pass" +} diff --git a/crates/amaru-ledger/tests/data/phase-one/schema.json b/crates/amaru-ledger/tests/data/phase-one/schema.json index 356a68fde8..2e981b6f12 100644 --- a/crates/amaru-ledger/tests/data/phase-one/schema.json +++ b/crates/amaru-ledger/tests/data/phase-one/schema.json @@ -254,6 +254,25 @@ } } }, + "CommitteeMember": { + "type": "object", + "required": ["coldCredential"], + "properties": { + "coldCredential": { + "$ref": "#/$defs/HexCbor", + "description": "Hex-encoded CBOR of StakeCredential. The cold credential keying the member." + }, + "hotCredential": { + "$ref": "#/$defs/HexCbor", + "description": "Hex-encoded CBOR of StakeCredential. The authorized hot credential, which is the identity a vote carries. Absent for a member that has never authorized one or has resigned." + }, + "validUntil": { + "type": "integer", + "minimum": 0, + "description": "Epoch through which the member's term runs. Absent for a member holding no term, which is still a state a member can authorize a hot credential from." + } + } + }, "InitialState": { "type": "object", "properties": { @@ -276,6 +295,11 @@ "items": { "$ref": "#/$defs/DRepRegistration" }, "description": "Registered delegate representatives. Empty when no DRep state is seeded." }, + "committee": { + "type": "array", + "items": { "$ref": "#/$defs/CommitteeMember" }, + "description": "Constitutional committee members, keyed by cold credential. Empty when no committee state is seeded." + }, "proposals": { "type": "array", "items": { "$ref": "#/$defs/ProposalEntry" }, @@ -298,12 +322,15 @@ } }, "ProposalEntry": { - "type": "object", - "required": ["id", "govAction"], - "properties": { - "id": { "$ref": "#/$defs/ProposalId" }, - "govAction": { "type": "string", "description": "CBOR-hex of the proposal's governance action." } - } + "type": "array", + "items": [ + { "$ref": "#/$defs/ProposalId" }, + { "$ref": "#/$defs/ProposalKind" } + ] + }, + "ProposalKind": { + "type": "string", + "enum": ["ProtocolParameters", "HardFork", "ConstitutionalCommittee", "Constitution", "Orphan"] }, "ProposalsRoots": { "type": "object", diff --git a/crates/amaru-ledger/tests/data/rules-conformance.failures.toml b/crates/amaru-ledger/tests/data/rules-conformance.failures.toml index db79fce365..e10761b83b 100644 --- a/crates/amaru-ledger/tests/data/rules-conformance.failures.toml +++ b/crates/amaru-ledger/tests/data/rules-conformance.failures.toml @@ -31,7 +31,6 @@ "conway/fail-gov-expired-gov-actions/0" = "Expected failure, got success" "conway/fail-gov-non-existent-gov-actions/0" = "Expected failure, got success" "conway/fail-gov-policy-is-respected-by-proposals/0" = "Expected failure, got success" -"conway/fail-gov-votersdonotexist/0" = "Expected failure, got success" "conway/fail-govcert-resigning-a-non-cc-key/0" = "Expected failure, got success" "conway/fail-utxos-alwaysfails-plutus-govpolicy-does-not-validate/0" = "Expected failure, got success" "conway/fail-utxos-delegtxcert-v1/0" = "Expected failure, got success" diff --git a/crates/amaru-observability/src/schemas.rs b/crates/amaru-observability/src/schemas.rs index c515b8bdd2..a533692129 100644 --- a/crates/amaru-observability/src/schemas.rs +++ b/crates/amaru-observability/src/schemas.rs @@ -463,6 +463,8 @@ define_schemas! { public HYDRATE { optional from_volatile: u64 optional from_db: u64 + /// Members scanned to match a vote's hot credential, which is not a store key. + optional scanned: u64 } } proposals { diff --git a/crates/amaru-plutus/src/script_context/v3.rs b/crates/amaru-plutus/src/script_context/v3.rs index a26a76b7ae..35e5b7f7b4 100644 --- a/crates/amaru-plutus/src/script_context/v3.rs +++ b/crates/amaru-plutus/src/script_context/v3.rs @@ -275,7 +275,7 @@ impl ToPlutusData<3> for Constitution { impl ToPlutusData<3> for ProposalId { fn to_plutus_data(&self) -> Result { - constr_v3!(0, [self.transaction_id, self.action_index]) + constr_v3!(0, [self.transaction_id, self.proposal_index]) } } diff --git a/docs/TRACES.md b/docs/TRACES.md index af17c28758..c66c71c89b 100644 --- a/docs/TRACES.md +++ b/docs/TRACES.md @@ -1533,7 +1533,7 @@ For information on how to use and filter these spans, see [monitoring/README.md] | name | level | public | description | required fields | optional fields | | --- | --- | --- | --- | --- | --- | -| `hydrate` | `TRACE` | public | Resolve committee members from the volatile db or the stable one | | from_volatile, from_db | +| `hydrate` | `TRACE` | public | Resolve committee members from the volatile db or the stable one | | from_volatile, from_db, scanned |
span: `hydrate` @@ -1541,6 +1541,7 @@ For information on how to use and filter these spans, see [monitoring/README.md] | --- | --- | --- | | `from_volatile` | `integer` | | | `from_db` | `integer` | | +| `scanned` | `integer` | |
diff --git a/docs/traces-schema.json b/docs/traces-schema.json index c9641d4af9..ff259a74e5 100644 --- a/docs/traces-schema.json +++ b/docs/traces-schema.json @@ -3343,12 +3343,16 @@ }, "from_db": { "type": "integer" + }, + "scanned": { + "type": "integer" } }, "required": [], "optional": [ "from_volatile", - "from_db" + "from_db", + "scanned" ], "additionalProperties": false, "name": "hydrate", From 8a7db4836b0aeafb343799a7ef89e32762e0b9b4 Mon Sep 17 00:00:00 2001 From: yHSJ Date: Mon, 3 Aug 2026 18:01:40 -0500 Subject: [PATCH 2/5] feat(test): introduce failing tests for GovActionsDoNotExist Signed-off-by: yHSJ --- .../rules/transaction/phase_one/fixture.rs | 4 ++ .../phase_one/voting_procedures.rs | 3 ++ .../fail/GovActionsDoNotExist/0.json | 50 +++++++++++++++++++ .../fail/GovActionsDoNotExist/1.json | 37 ++++++++++++++ .../fail/GovActionsDoNotExist/2.json | 43 ++++++++++++++++ .../fail/GovActionsDoNotExist/3.json | 50 +++++++++++++++++++ 6 files changed, 187 insertions(+) create mode 100644 crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/0.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/1.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/2.json create mode 100644 crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/3.json diff --git a/crates/amaru-ledger/src/rules/transaction/phase_one/fixture.rs b/crates/amaru-ledger/src/rules/transaction/phase_one/fixture.rs index 5dabd2c54d..4cadead14f 100644 --- a/crates/amaru-ledger/src/rules/transaction/phase_one/fixture.rs +++ b/crates/amaru-ledger/src/rules/transaction/phase_one/fixture.rs @@ -239,6 +239,7 @@ pub(super) enum Predicate { ConwayTxRefScriptsSizeTooBig, ConwayWdrlNotDelegatedToDRep, FeeTooSmallUTxO, + GovActionsDoNotExist, IncorrectDepositDELEG, IncorrectTotalCollateralField, ConwayTreasuryValueMismatch, @@ -342,6 +343,9 @@ impl From for Predicate { PhaseOneError::VotingProcedures(InvalidVotingProcedures::VotersDoNotExist(_)) => { Predicate::VotersDoNotExist } + PhaseOneError::VotingProcedures(InvalidVotingProcedures::GovActionsDoNotExist(_)) => { + Predicate::GovActionsDoNotExist + } PhaseOneError::ValueNotPreserved(_) => Predicate::ValueNotConservedUTxO, PhaseOneError::Certificates(InvalidCertificates::StakeCredentialInvalidPoolDelegation(ref e)) => match e { DelegateError::UnknownSource(_) => Predicate::StakeCredentialInvalidPoolDelegation, diff --git a/crates/amaru-ledger/src/rules/transaction/phase_one/voting_procedures.rs b/crates/amaru-ledger/src/rules/transaction/phase_one/voting_procedures.rs index 69022ed535..3645546c4a 100644 --- a/crates/amaru-ledger/src/rules/transaction/phase_one/voting_procedures.rs +++ b/crates/amaru-ledger/src/rules/transaction/phase_one/voting_procedures.rs @@ -26,6 +26,9 @@ use crate::context::{CommitteeSlice, DRepsSlice, PoolsSlice, ProposalsSlice, Wit pub enum InvalidVotingProcedures { #[error("voters do not exist: {0:?}")] VotersDoNotExist(BTreeSet), + + #[error("governance actions do not exist: {0:?}")] + GovActionsDoNotExist(BTreeSet), } pub(crate) fn execute( diff --git a/crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/0.json b/crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/0.json new file mode 100644 index 0000000000..bc45f9ec3e --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/0.json @@ -0,0 +1,50 @@ +{ + "title": "vote cast on a governance action absent from the proposals state", + "description": "A registered DRep naming a proposal id that no proposal in state carries.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "8258207ae12fe7076ec75319e0ecac782bcb6d828bb6a4baa49eada7116b47a80d449200", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "pools": [], + "accounts": [], + "dreps": [ + { + "credential": "8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8", + "deposit": 500000000, + "registeredAt": { + "transaction": { + "slot": 0, + "transactionIndex": 0 + }, + "certificateIndex": 0 + }, + "validUntil": 1000 + } + ], + "committee": [], + "proposals": [ + "8258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b00" + ], + "governanceActivity": { + "consecutiveDormantEpochs": 0 + } + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d90102818258207ae12fe7076ec75319e0ecac782bcb6d828bb6a4baa49eada7116b47a80d4492000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18202581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a1825820bbd665a051206b767522b19427cced4e0420a050852f7ba1c875c9ec1e4983e1008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db1258405c8b74a8926ec66661779cff7512c53ef14cc243fb20907a1a62c841f9fdaef6d409301c96944bdbd5f355b077894b773717722e8fd7764a1c5fd1020f56540ff5f6", + "expected": { + "predicate": "GovActionsDoNotExist" + } +} diff --git a/crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/1.json b/crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/1.json new file mode 100644 index 0000000000..d612467037 --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/1.json @@ -0,0 +1,37 @@ +{ + "title": "vote cast on a governance action whose index does not match any proposal", + "description": "A proposal id sharing the seeded proposal's transaction id but naming a different action index.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "825820c25d414957cf674b477d9bec7c295a653f02aee955828f9ebec7a4ece7891f2900", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "pools": ["93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8"], + "accounts": [], + "dreps": [], + "committee": [], + "proposals": [ + "8258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b00" + ], + "governanceActivity": { + "consecutiveDormantEpochs": 0 + } + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d9010281825820c25d414957cf674b477d9bec7c295a653f02aee955828f9ebec7a4ece7891f29000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18204581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b018201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db1258406d6fd3a60b8e7a94ebd0dda9f809aa4adc4c3ea24c4c29b58aae7b7a7c70f0f8543d7c8efb406a5e37ca38d3c340c681aa8e7a7ef5837327d8ecb29d58aba60bf5f6", + "expected": { + "predicate": "GovActionsDoNotExist" + } +} diff --git a/crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/2.json b/crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/2.json new file mode 100644 index 0000000000..0b7c454359 --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/2.json @@ -0,0 +1,43 @@ +{ + "title": "vote cast by a committee member on a governance action that does not exist", + "description": "An authorized committee hot key naming an absent proposal id.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "82582033e3dfb56c093ef6a5e0a85e186f6c4cba64004d1c21d06d868e1168672b9cdc00", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "pools": [], + "accounts": [], + "dreps": [], + "committee": [ + { + "coldCredential": "8200581cecc3093b061a9ed426cd9956161fc48d4ed1ac58dc4aaed7e1ce1830", + "hotCredential": "8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8", + "validUntil": 200 + } + ], + "proposals": [ + "8258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b00" + ], + "governanceActivity": { + "consecutiveDormantEpochs": 0 + } + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d901028182582033e3dfb56c093ef6a5e0a85e186f6c4cba64004d1c21d06d868e1168672b9cdc000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a1825820bbd665a051206b767522b19427cced4e0420a050852f7ba1c875c9ec1e4983e1008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db1258407c0b8db38f40877a619d45a2da54831f6d6bec42d67d3fa70b4db8a1dca933eaf07e1adf533e05e439c404f15d57cba594cc6bc2155bfc7552d79526896e3002f5f6", + "expected": { + "predicate": "GovActionsDoNotExist" + } +} diff --git a/crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/3.json b/crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/3.json new file mode 100644 index 0000000000..b1a037acd6 --- /dev/null +++ b/crates/amaru-ledger/tests/data/phase-one/fail/GovActionsDoNotExist/3.json @@ -0,0 +1,50 @@ +{ + "title": "vote cast on two governance actions of which only one exists", + "description": "A single voter casting one ballot on the seeded proposal and one on an absent id.", + "network": "preprod", + "eraHistory": { + "$ref": "common/eraHistory/preprod-conway.json" + }, + "protocolParameters": { + "$ref": "common/protocolParameters/preprod-conway-v10.json" + }, + "initialState": { + "utxo": [ + { + "input": "8258202b648a1f906db52cc04eac0a58116b1ed17c7bacab29a2f592d325b12281cafc00", + "output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40" + } + ], + "pools": [], + "accounts": [], + "dreps": [ + { + "credential": "8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8", + "deposit": 500000000, + "registeredAt": { + "transaction": { + "slot": 0, + "transactionIndex": 0 + }, + "certificateIndex": 0 + }, + "validUntil": 1000 + } + ], + "committee": [], + "proposals": [ + "8258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b00" + ], + "governanceActivity": { + "consecutiveDormantEpochs": 0 + } + }, + "point": { + "slot": 0, + "transactionIndex": 0 + }, + "transaction": "84a500d90102818258202b648a1f906db52cc04eac0a58116b1ed17c7bacab29a2f592d325b12281cafc000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18202581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a28258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6825820bbd665a051206b767522b19427cced4e0420a050852f7ba1c875c9ec1e4983e1008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db125840f608a4bbfed1cf60d841586def7f826ba2d65241612d4b5e0512a1285024aae057abf43a3e38db8e57cbe23fe3b95f09a6633030139959a72bcda8f2122c5e0df5f6", + "expected": { + "predicate": "GovActionsDoNotExist" + } +} From 743dfd44a57176d79b86d03ba586587629f2c20a Mon Sep 17 00:00:00 2001 From: yHSJ Date: Mon, 3 Aug 2026 18:29:38 -0500 Subject: [PATCH 3/5] feat: implement GovActionsDoNotExist Signed-off-by: yHSJ --- .../phase_one/voting_procedures.rs | 20 +++++++++++++++++-- .../data/rules-conformance.failures.toml | 1 - 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/amaru-ledger/src/rules/transaction/phase_one/voting_procedures.rs b/crates/amaru-ledger/src/rules/transaction/phase_one/voting_procedures.rs index 3645546c4a..5a47342e7c 100644 --- a/crates/amaru-ledger/src/rules/transaction/phase_one/voting_procedures.rs +++ b/crates/amaru-ledger/src/rules/transaction/phase_one/voting_procedures.rs @@ -41,13 +41,29 @@ where if let Some(voting_procedures) = voting_procedures { let voting_procedures = voting_procedures.into_iter().collect::>(); - let unknown_voters = - voting_procedures.keys().filter(|voter| !exists(context, voter)).cloned().collect::>(); + let mut unknown_voters = BTreeSet::new(); + let mut unknown_proposals = BTreeSet::new(); + + for (voter, votes) in voting_procedures.iter() { + if !exists(context, voter) { + unknown_voters.insert(voter.clone()); + } + + for (proposal_id, _) in votes.iter() { + if !ProposalsSlice::exists(context, proposal_id) { + unknown_proposals.insert(*proposal_id); + } + } + } if !unknown_voters.is_empty() { return Err(InvalidVotingProcedures::VotersDoNotExist(unknown_voters)); } + if !unknown_proposals.is_empty() { + return Err(InvalidVotingProcedures::GovActionsDoNotExist(unknown_proposals)); + } + voting_procedures.into_iter().enumerate().for_each(|(index, (voter, votes))| { match voter.owner() { StakeCredential::ScriptHash(hash) => { diff --git a/crates/amaru-ledger/tests/data/rules-conformance.failures.toml b/crates/amaru-ledger/tests/data/rules-conformance.failures.toml index e10761b83b..ae7ab4e55c 100644 --- a/crates/amaru-ledger/tests/data/rules-conformance.failures.toml +++ b/crates/amaru-ledger/tests/data/rules-conformance.failures.toml @@ -29,7 +29,6 @@ # PassEpoch event that this harness skips, so the empty prev-gov-action-id can't be rejected here. "conway/fail-gov-empty-prevgovid-after-the-first-constitution-was-enacted/0" = "Expected failure, got success" "conway/fail-gov-expired-gov-actions/0" = "Expected failure, got success" -"conway/fail-gov-non-existent-gov-actions/0" = "Expected failure, got success" "conway/fail-gov-policy-is-respected-by-proposals/0" = "Expected failure, got success" "conway/fail-govcert-resigning-a-non-cc-key/0" = "Expected failure, got success" "conway/fail-utxos-alwaysfails-plutus-govpolicy-does-not-validate/0" = "Expected failure, got success" From ffbb2811292e1511b331c25db2f462a42dbaf847 Mon Sep 17 00:00:00 2001 From: yHSJ Date: Mon, 3 Aug 2026 18:40:27 -0500 Subject: [PATCH 4/5] feat(changelog): add a changelog entry for #924 Signed-off-by: yHSJ --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e6c4cf2b..ce88340bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,8 @@ Other guiding principles: - **amaru-ledger**: compute rewards and stake distributions asynchronously to prevent blocking the main roll forward loop from times to times. - **amaru**: bootstrap snapshots now are retrieved directly from R2 (no embedded manifests) and compressed with zstandard. ([#1012][]) - **amaru**: metrics are now (also) exported through gRPC on `:4317` by default instead of `:4318` over HTTP. +- **amaru-ledger**: validate voters in a transaction actually exist in ledger state. ([#1138][], [#923][]) +- **amaru-ledger**: validate the governance actions a transaction votes on actually exist, counting proposals submitted earlier in the same block. ([#924][]) ### Removed @@ -276,6 +278,7 @@ Other guiding principles: [#915]: https://github.com/pragma-org/amaru/issues/915 [#923]: https://github.com/pragma-org/amaru/issues/923 [#928]: https://github.com/pragma-org/amaru/issues/928 +[#924]: https://github.com/pragma-org/amaru/issues/924 [#929]: https://github.com/pragma-org/amaru/issues/929 [#932]: https://github.com/pragma-org/amaru/issues/932 [#942]: https://github.com/pragma-org/amaru/pull/942 From 378279a992459daabf4758136f9625f05b72ad21 Mon Sep 17 00:00:00 2001 From: yHSJ Date: Mon, 3 Aug 2026 18:45:42 -0500 Subject: [PATCH 5/5] chore(changelog): update changelog entry to include PR number Signed-off-by: yHSJ --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce88340bc1..b55b80f3fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Other guiding principles: ### Changed - **amaru-ledger**: validate voters in a transaction actually exist in ledger state. ([#1138][], [#923][]) +- **amaru-ledger**: validate the governance actions a transaction votes on actually exist, counting proposals submitted earlier in the same block. ([#1139][], [#924][]) ### Fixed @@ -66,8 +67,6 @@ Other guiding principles: - **amaru-ledger**: compute rewards and stake distributions asynchronously to prevent blocking the main roll forward loop from times to times. - **amaru**: bootstrap snapshots now are retrieved directly from R2 (no embedded manifests) and compressed with zstandard. ([#1012][]) - **amaru**: metrics are now (also) exported through gRPC on `:4317` by default instead of `:4318` over HTTP. -- **amaru-ledger**: validate voters in a transaction actually exist in ledger state. ([#1138][], [#923][]) -- **amaru-ledger**: validate the governance actions a transaction votes on actually exist, counting proposals submitted earlier in the same block. ([#924][]) ### Removed @@ -277,8 +276,8 @@ Other guiding principles: [#912]: https://github.com/pragma-org/amaru/issues/912 [#915]: https://github.com/pragma-org/amaru/issues/915 [#923]: https://github.com/pragma-org/amaru/issues/923 -[#928]: https://github.com/pragma-org/amaru/issues/928 [#924]: https://github.com/pragma-org/amaru/issues/924 +[#928]: https://github.com/pragma-org/amaru/issues/928 [#929]: https://github.com/pragma-org/amaru/issues/929 [#932]: https://github.com/pragma-org/amaru/issues/932 [#942]: https://github.com/pragma-org/amaru/pull/942 @@ -333,3 +332,4 @@ Other guiding principles: [#1109]: https://github.com/pragma-org/amaru/pull/1109 [#1118]: https://github.com/pragma-org/amaru/pull/1118 [#1138]: https://github.com/pragma-org/amaru/pull/1138 +[#1139]: https://github.com/pragma-org/amaru/pull/1139