Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Other guiding principles:
- **amaru-ledger**: validate the governance actions a transaction votes on actually exist, counting proposals submitted earlier in the same block. ([#1139][], [#924][])
- **amaru-ledger**: reject votes cast on governance actions that have expired. A proposal's expiry is now stamped once, when it is submitted, and carried through the volatile state, which will resolve some potential bugs. ([#1143][], [#926][])
- **amaru-ledger**: from protocol version 11 on, reject votes cast by constitutional committee members the *elected* committee does not name, even when they hold an authorized hot credential. ([#1157][], [#922][])
- **amaru-ledger**: reject votes cast on a kind of governance action the voter has no say over. ([#927][])

## [v10.11.20260806](https://github.com/pragma-org/amaru/releases/tag/v10.11.20260807)

Expand Down Expand Up @@ -277,6 +278,7 @@ Other guiding principles:
[#923]: https://github.com/pragma-org/amaru/issues/923
[#924]: https://github.com/pragma-org/amaru/issues/924
[#926]: https://github.com/pragma-org/amaru/issues/926
[#927]: https://github.com/pragma-org/amaru/issues/927
[#928]: https://github.com/pragma-org/amaru/issues/928
[#929]: https://github.com/pragma-org/amaru/issues/929
[#942]: https://github.com/pragma-org/amaru/pull/942
Expand Down
57 changes: 56 additions & 1 deletion crates/amaru-kernel/src/cardano/protocol_parameters_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use crate::{
CostModels, DRepVotingThresholds, ExUnitPrices, ExUnits, Lovelace, PoolVotingThresholds, RationalNumber, cbor,
};

#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, cbor::Encode, cbor::Decode)]
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, cbor::Encode, cbor::Decode)]
#[cbor(map)]
pub struct ProtocolParamUpdate {
#[n(0)]
Expand Down Expand Up @@ -83,6 +83,22 @@ pub struct ProtocolParamUpdate {
pub minfee_refscript_cost_per_byte: Option<RationalNumber>,
}

impl ProtocolParamUpdate {
/// Whether the update touches any parameter of the 'security group'.
pub fn modifies_security_group(&self) -> bool {
self.minfee_a.is_some()
|| self.minfee_b.is_some()
|| self.max_block_body_size.is_some()
|| self.max_block_header_size.is_some()
|| self.max_transaction_size.is_some()
|| self.ada_per_utxo_byte.is_some()
|| self.max_block_ex_units.is_some()
|| self.max_value_size.is_some()
|| self.governance_action_deposit.is_some()
|| self.minfee_refscript_cost_per_byte.is_some()
}
}

pub fn display_protocol_parameters_update(update: &ProtocolParamUpdate, prefix: &str) -> Result<String, fmt::Error> {
let mut s = String::new();

Expand Down Expand Up @@ -229,3 +245,42 @@ pub fn display_protocol_parameters_update(update: &ProtocolParamUpdate, prefix:

Ok(s)
}

#[cfg(test)]
mod tests {
use test_case::test_case;

use super::*;

fn one() -> RationalNumber {
RationalNumber { numerator: 1, denominator: 1 }
}

#[test_case(|update| update.minfee_a = Some(1); "minfee_a")]
#[test_case(|update| update.minfee_b = Some(1); "minfee_b")]
#[test_case(|update| update.max_block_body_size = Some(1); "max_block_body_size")]
#[test_case(|update| update.max_block_header_size = Some(1); "max_block_header_size")]
#[test_case(|update| update.max_transaction_size = Some(1); "max_transaction_size")]
#[test_case(|update| update.ada_per_utxo_byte = Some(1); "ada_per_utxo_byte")]
#[test_case(|update| update.max_block_ex_units = Some(ExUnits { mem: 1, steps: 1 }); "max_block_ex_units")]
#[test_case(|update| update.max_value_size = Some(1); "max_value_size")]
#[test_case(|update| update.governance_action_deposit = Some(1); "governance_action_deposit")]
#[test_case(|update| update.minfee_refscript_cost_per_byte = Some(one()); "minfee_refscript_cost_per_byte")]
fn in_security_group(modify: fn(&mut ProtocolParamUpdate)) {
let mut update = ProtocolParamUpdate::default();
modify(&mut update);
assert!(update.modifies_security_group());
}

#[test_case(|_| (); "nothing modified at all")]
#[test_case(|update| update.key_deposit = Some(1); "key_deposit")]
#[test_case(|update| update.max_tx_ex_units = Some(ExUnits { mem: 1, steps: 1 }); "max_tx_ex_units")]
#[test_case(|update| update.execution_costs = Some(ExUnitPrices { mem_price: one(), step_price: one() }); "execution_costs")]
#[test_case(|update| update.cost_models_for_script_languages = Some(CostModels { plutus_v1: None, plutus_v2: None, plutus_v3: None }); "cost_models")]
#[test_case(|update| update.drep_deposit = Some(1); "drep_deposit")]
fn out_of_security_group(modify: fn(&mut ProtocolParamUpdate)) {
let mut update = ProtocolParamUpdate::default();
modify(&mut update);
assert!(!update.modifies_security_group());
}
}
20 changes: 2 additions & 18 deletions crates/amaru-ledger/src/governance/ratification/stake_pools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

use std::collections::BTreeMap;

use amaru_kernel::{DRep, PoolId, PoolVotingThresholds, ProtocolParamUpdate, Vote};
use amaru_kernel::{DRep, PoolId, PoolVotingThresholds, Vote};
use num::Zero;

use super::{CommitteeUpdate, OrphanProposal, ProposalEnum};
Expand All @@ -37,7 +37,7 @@ pub fn voting_threshold(
) -> Option<SafeRatio> {
match proposal {
ProposalEnum::ProtocolParameters(params_update, _) => {
if any_update_in_security_group(params_update) {
if params_update.modifies_security_group() {
Some(into_safe_ratio(&voting_thresholds.security_voting_threshold))
} else {
Some(SafeRatio::zero())
Expand Down Expand Up @@ -66,22 +66,6 @@ pub fn voting_threshold(
}
}

// Check whether the update contains any parameter that is considered part of the 'security group'.
// Those parameters require approval from the SPO to be changed. Others are only in the hands of
// DReps & Constitutional Committee.
fn any_update_in_security_group(update: &ProtocolParamUpdate) -> bool {
update.minfee_a.is_some()
|| update.minfee_b.is_some()
|| update.max_block_body_size.is_some()
|| update.max_block_header_size.is_some()
|| update.max_transaction_size.is_some()
|| update.ada_per_utxo_byte.is_some()
|| update.max_block_ex_units.is_some()
|| update.max_value_size.is_some()
|| update.governance_action_deposit.is_some()
|| update.minfee_refscript_cost_per_byte.is_some()
}

// Tally
// ----------------------------------------------------------------------------

Expand Down
11 changes: 7 additions & 4 deletions crates/amaru-ledger/src/rules/transaction/phase_one/fixture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,11 +205,10 @@ struct ProposalProxy {
#[serde(deserialize_with = "deserialize_cbor_hex")]
id: ProposalId,
valid_until: Epoch,
#[serde(deserialize_with = "deserialize_cbor_hex")]
gov_action: GovernanceAction,
}

/// The identity and expiry of a seeded proposal are the only parts a fixture states; the rest of
/// [`ProposalState`] is stood up as an `Information` action, the one governance action that
/// constrains nothing about who may vote on it.
fn deserialize_proposals<'de, D>(deserializer: D) -> Result<BTreeMap<ProposalId, ProposalState>, D::Error>
where
D: serde::Deserializer<'de>,
Expand All @@ -227,7 +226,7 @@ where
proposal: Proposal {
deposit: 0,
reward_account: RewardAccount::from(vec![]),
gov_action: GovernanceAction::Information,
gov_action: entry.gov_action,
anchor: Anchor { content_hash: Hash::new([0; 32]), url: String::new() },
},
};
Expand Down Expand Up @@ -266,6 +265,7 @@ pub(super) enum Predicate {
ConflictingMetadataHash,
ConwayTxRefScriptsSizeTooBig,
ConwayWdrlNotDelegatedToDRep,
DisallowedVoters,
FeeTooSmallUTxO,
GovActionsDoNotExist,
IncorrectDepositDELEG,
Expand Down Expand Up @@ -378,6 +378,9 @@ impl From<PhaseOneError> for Predicate {
PhaseOneError::VotingProcedures(InvalidVotingProcedures::VotingOnExpiredGovAction(_, _)) => {
Predicate::VotingOnExpiredGovAction
}
PhaseOneError::VotingProcedures(InvalidVotingProcedures::DisallowedVoter(_, _)) => {
Predicate::DisallowedVoters
}
PhaseOneError::ValueNotPreserved(_) => Predicate::ValueNotConservedUTxO,
PhaseOneError::Certificates(InvalidCertificates::StakeCredentialInvalidPoolDelegation(ref e)) => match e {
DelegateError::UnknownSource(_) => Predicate::StakeCredentialInvalidPoolDelegation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
use std::collections::{BTreeMap, BTreeSet};

use amaru_kernel::{
EraHistory, HasOwnership, MemoizedDatum, NonEmptyKeyValuePairs, ProposalId, ProtocolVersion, RedeemerTag,
RequiredScript, StakeCredential, TransactionPointer, Voter, VotingProcedure,
EraHistory, GovernanceAction, HasOwnership, MemoizedDatum, NonEmptyKeyValuePairs, ProposalId, ProtocolVersion,
RedeemerTag, RequiredScript, StakeCredential, TransactionPointer, Voter, VotingProcedure,
};
use thiserror::Error;

Expand All @@ -36,6 +36,9 @@ pub enum InvalidVotingProcedures {
#[error("votes cast on governance actions that have expired: Voter {0:?} on proposal {1:?}")]
VotingOnExpiredGovAction(Voter, ProposalId),

#[error("vote cast on a governance action the voter has no say over: Voter {0:?} on proposal {1:?}")]
DisallowedVoter(Voter, ProposalId),

#[error("era history error: {0}")]
EraHistory(#[from] amaru_kernel::EraHistoryError),
}
Expand Down Expand Up @@ -79,6 +82,10 @@ where
return Err(InvalidVotingProcedures::VotingOnExpiredGovAction(voter.clone(), *proposal_id));
}

Some(state) if !is_entitled_to_vote(voter, &state.proposal.gov_action) => {
return Err(InvalidVotingProcedures::DisallowedVoter(voter.clone(), *proposal_id));
}

Some(..) => {}
}
}
Expand Down Expand Up @@ -114,7 +121,7 @@ where
Ok(())
}

/// Election statushere is membership, not an unexpired term: a member whose term has run out is still
/// Election status here is membership, not an unexpired term: a member whose term has run out is still
/// named by the committee, and their vote is discounted when the action is ratified instead.
///
/// Voters that are not committee members are never rejected by this check.
Expand All @@ -132,6 +139,28 @@ where
}
}

/// Whether a voter has any say over this kind of governance action
fn is_entitled_to_vote(voter: &Voter, action: &GovernanceAction) -> bool {
match voter {
Voter::ConstitutionalCommitteeKey(..) | Voter::ConstitutionalCommitteeScript(..) => {
!matches!(action, GovernanceAction::NoConfidence(..) | GovernanceAction::UpdateCommittee(..))
}

Voter::StakePoolKey(..) => match action {
GovernanceAction::ParameterChange(_, update, _) => update.modifies_security_group(),

GovernanceAction::NewConstitution(..) | GovernanceAction::TreasuryWithdrawals(..) => false,

GovernanceAction::NoConfidence(..)
| GovernanceAction::UpdateCommittee(..)
| GovernanceAction::HardForkInitiation(..)
| GovernanceAction::Information => true,
},

Voter::DRepKey(..) | Voter::DRepScript(..) => true,
}
}

/// Whether the entity a vote is cast by is known at this point in the block.
fn exists<C>(context: &C, voter: &Voter) -> bool
where
Expand Down
10 changes: 4 additions & 6 deletions crates/amaru-ledger/tests/data/phase-one/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,10 @@ it is made up of seven fields:
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, validUntil }]`, the governance actions that are already on the
chain and can therefore be voted on or referenced as an ancestor. `id` is hex-encoded
CBOR of a `GovActionId` and `validUntil` is the last epoch in which a vote on the
action still counts. The action itself is not stated: the harness stands it up as an
`Information` action, which constrains nothing about who may vote on it. A rule that
needs the action's type or its proposing pointer has to extend this schema.
- `proposals`: `[{ id, validUntil, govAction }]`, the governance actions that are already
on the chain and can therefore be voted on or referenced as an ancestor. `id` is
hex-encoded CBOR of a `GovActionId`, `validUntil` is the last epoch in which a vote on
the action still counts, and `govAction` is hex-encoded CBOR of the `GovAction` itself.

`protocolParameters` is loosely inspired by [Ogmios](https://github.com/CardanoSolutions/ogmios)
but intentionally diverges:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"title": "vote cast by a committee member on a motion of no confidence",
"description": "The constitutional committee has no say over the action that dissolves it.",
"network": "preprod",
"eraHistory": {
"$ref": "common/eraHistory/preprod-conway.json"
},
"protocolParameters": {
"$ref": "common/protocolParameters/preprod-conway-v10.json"
},
"initialState": {
"utxo": [
{
"input": "825820b2d63157407a6741a64c9dbb84b29a02a931f2e8d469a7b9bd5f2bf78e06658d00",
"output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40"
}
],
"pools": [],
"accounts": [],
"dreps": [],
"committee": [
{
"coldCredential": "8200581cecc3093b061a9ed426cd9956161fc48d4ed1ac58dc4aaed7e1ce1830",
"hotCredential": "8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8",
"validUntil": 200
}
],
"proposals": [
{
"id": "8258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b00",
"validUntil": 100,
"govAction": "8203f6"
}
],
"governanceActivity": {
"consecutiveDormantEpochs": 0
},
"pots": {
"treasury": 0,
"reserves": 0
}
},
"point": {
"slot": 0,
"transactionIndex": 0
},
"transaction": "84a500d9010281825820b2d63157407a6741a64c9dbb84b29a02a931f2e8d469a7b9bd5f2bf78e06658d000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db1258401b27d78d82ad1dfb14670f9230ee04ff38278579ab6452c70e24e5ea68048ccfa7adbe2e9fa24b453d9a0cad509110bdad1247c5f021bb81187f99cf42c2f70cf5f6",
"expected": {
"predicate": "DisallowedVoters"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"title": "vote cast by a committee member on a committee update",
"description": "The constitutional committee has no say over the action that reshapes its own membership.",
"network": "preprod",
"eraHistory": {
"$ref": "common/eraHistory/preprod-conway.json"
},
"protocolParameters": {
"$ref": "common/protocolParameters/preprod-conway-v10.json"
},
"initialState": {
"utxo": [
{
"input": "82582052b8dca44ab180eda06355c059f22641b6410935f97852fa70cdd0d3d689f32b00",
"output": "a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a004c4b40"
}
],
"pools": [],
"accounts": [],
"dreps": [],
"committee": [
{
"coldCredential": "8200581cecc3093b061a9ed426cd9956161fc48d4ed1ac58dc4aaed7e1ce1830",
"hotCredential": "8200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8",
"validUntil": 200
}
],
"proposals": [
{
"id": "8258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b00",
"validUntil": 100,
"govAction": "8504f6d9010280a0d81e820102"
}
],
"governanceActivity": {
"consecutiveDormantEpochs": 0
},
"pots": {
"treasury": 0,
"reserves": 0
}
},
"point": {
"slot": 0,
"transactionIndex": 0
},
"transaction": "84a500d901028182582052b8dca44ab180eda06355c059f22641b6410935f97852fa70cdd0d3d689f32b000181a200581d6093c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8011a00493e00021a00030d400f0013a18200581c93c191b1094746961f6f00fba27f3d8eff6a66490baf806d4e179fd8a18258207e2b9d5f1a3c7e5b9d1f3a5c7e9b1d3f5a7c9e1b3d5f7a9c1e3b5d7f9a1c3e5b008201f6a100d90102818258202152f8d19b791d24453242e15f2eab6cb7cffa7b6a5ed30097960e069881db1258401e909f5383d167876434d3f264da2fc51eaa22727866c6084eea3945c3aed31d8f871833ff3f2d203b1d31cfbc73cd2411f599dd81c8993225d6a84ecb1e0a08f5f6",
"expected": {
"predicate": "DisallowedVoters"
}
}
Loading
Loading