Redesign Slash/SlashReport types with a function to calculate the penalty

This commit is contained in:
Luke Parker
2025-01-14 07:51:39 -05:00
parent 291ebf5e24
commit a7fef2ba7a
11 changed files with 307 additions and 52 deletions

View File

@@ -141,7 +141,7 @@ pub mod pallet {
_,
Identity,
NetworkId,
BoundedVec<(Public, u64), ConstU32<{ MAX_KEY_SHARES_PER_SET }>>,
BoundedVec<(Public, u64), ConstU32<{ MAX_KEY_SHARES_PER_SET_U32 }>>,
OptionQuery,
>;
/// The validators selected to be in-set, regardless of if removed.
@@ -402,7 +402,7 @@ pub mod pallet {
// Clear the current InSet
assert_eq!(
InSet::<T>::clear_prefix(network, MAX_KEY_SHARES_PER_SET, None).maybe_cursor,
InSet::<T>::clear_prefix(network, MAX_KEY_SHARES_PER_SET_U32, None).maybe_cursor,
None
);
@@ -412,11 +412,11 @@ pub mod pallet {
{
let mut iter = SortedAllocationsIter::<T>::new(network);
let mut key_shares = 0;
while key_shares < u64::from(MAX_KEY_SHARES_PER_SET) {
while key_shares < u64::from(MAX_KEY_SHARES_PER_SET_U32) {
let Some((key, amount)) = iter.next() else { break };
let these_key_shares =
(amount.0 / allocation_per_key_share).min(u64::from(MAX_KEY_SHARES_PER_SET));
(amount.0 / allocation_per_key_share).min(u64::from(MAX_KEY_SHARES_PER_SET_U32));
participants.push((key, these_key_shares));
key_shares += these_key_shares;
@@ -535,7 +535,7 @@ pub mod pallet {
top = Some(key_shares);
}
if key_shares > u64::from(MAX_KEY_SHARES_PER_SET) {
if key_shares > u64::from(MAX_KEY_SHARES_PER_SET_U32) {
break;
}
}
@@ -547,7 +547,7 @@ pub mod pallet {
// post_amortization_key_shares_for_top_validator yields what the top validator's key shares
// would be after such a reduction, letting us evaluate this correctly
let top = post_amortization_key_shares_for_top_validator(validators_len, top, key_shares);
(top * 3) < key_shares.min(MAX_KEY_SHARES_PER_SET.into())
(top * 3) < key_shares.min(MAX_KEY_SHARES_PER_SET_U32.into())
}
fn increase_allocation(
@@ -586,7 +586,7 @@ pub mod pallet {
// The above is_bft calls are only used to check a BFT net doesn't become non-BFT
// Check here if this call would prevent a non-BFT net from *ever* becoming BFT
if (new_allocation / allocation_per_key_share) >= (MAX_KEY_SHARES_PER_SET / 3).into() {
if (new_allocation / allocation_per_key_share) >= (MAX_KEY_SHARES_PER_SET_U32 / 3).into() {
Err(Error::<T>::AllocationWouldPreventFaultTolerance)?;
}
@@ -1010,7 +1010,7 @@ pub mod pallet {
pub fn report_slashes(
origin: OriginFor<T>,
network: NetworkId,
slashes: BoundedVec<(Public, u32), ConstU32<{ MAX_KEY_SHARES_PER_SET / 3 }>>,
slashes: BoundedVec<(Public, u32), ConstU32<{ MAX_KEY_SHARES_PER_SET_U32 / 3 }>>,
signature: Signature,
) -> DispatchResult {
ensure_none(origin)?;
@@ -1209,7 +1209,7 @@ pub mod pallet {
ValidTransaction::with_tag_prefix("ValidatorSets")
.and_provides((1, set))
.longevity(MAX_KEY_SHARES_PER_SET.into())
.longevity(MAX_KEY_SHARES_PER_SET_U32.into())
.propagate(true)
.build()
}

View File

@@ -1,5 +1,7 @@
#![cfg_attr(not(feature = "std"), no_std)]
use core::time::Duration;
#[cfg(feature = "std")]
use zeroize::Zeroize;
@@ -13,20 +15,30 @@ use borsh::{BorshSerialize, BorshDeserialize};
#[cfg(feature = "serde")]
use serde::{Serialize, Deserialize};
use sp_core::{ConstU32, sr25519::Public, bounded::BoundedVec};
use sp_core::{ConstU32, bounded::BoundedVec, sr25519::Public};
#[cfg(not(feature = "std"))]
use sp_std::vec::Vec;
use serai_primitives::NetworkId;
/// The maximum amount of key shares per set.
pub const MAX_KEY_SHARES_PER_SET: u32 = 150;
mod slash_points;
pub use slash_points::*;
/// The expected duration for a session.
// 1 week
pub const SESSION_LENGTH: Duration = Duration::from_secs(7 * 24 * 60 * 60);
/// The maximum length for a key.
// Support keys up to 96 bytes (BLS12-381 G2).
pub const MAX_KEY_LEN: u32 = 96;
/// The maximum amount of key shares per set.
pub const MAX_KEY_SHARES_PER_SET: u16 = 150;
pub const MAX_KEY_SHARES_PER_SET_U32: u32 = MAX_KEY_SHARES_PER_SET as u32;
/// The type used to identify a specific session of validators.
#[derive(
Clone, Copy, PartialEq, Eq, Hash, Default, Debug, Encode, Decode, TypeInfo, MaxEncodedLen,
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Encode, Decode, TypeInfo, MaxEncodedLen,
)]
#[cfg_attr(feature = "std", derive(Zeroize))]
#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))]
@@ -34,7 +46,9 @@ pub const MAX_KEY_LEN: u32 = 96;
pub struct Session(pub u32);
/// The type used to identify a specific validator set during a specific session.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Encode, Decode, TypeInfo, MaxEncodedLen)]
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Encode, Decode, TypeInfo, MaxEncodedLen,
)]
#[cfg_attr(feature = "std", derive(Zeroize))]
#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
@@ -43,13 +57,13 @@ pub struct ValidatorSet {
pub network: NetworkId,
}
type MaxKeyLen = ConstU32<MAX_KEY_LEN>;
/// The type representing a Key from an external network.
pub type ExternalKey = BoundedVec<u8, MaxKeyLen>;
pub type ExternalKey = BoundedVec<u8, ConstU32<MAX_KEY_LEN>>;
/// The key pair for a validator set.
///
/// This is their Ristretto key, used for signing Batches, and their key on the external network.
/// This is their Ristretto key, used for publishing data onto Serai, and their key on the external
/// network.
#[derive(Clone, PartialEq, Eq, Debug, Encode, Decode, TypeInfo, MaxEncodedLen)]
#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
@@ -81,12 +95,12 @@ impl Zeroize for KeyPair {
/// The MuSig context for a validator set.
pub fn musig_context(set: ValidatorSet) -> Vec<u8> {
[b"ValidatorSets-musig_key".as_ref(), &set.encode()].concat()
(b"ValidatorSets-musig_key".as_ref(), set).encode()
}
/// The MuSig public key for a validator set.
///
/// This function panics on invalid input.
/// This function panics on invalid input, per the definition of `dkg::musig::musig_key`.
pub fn musig_key(set: ValidatorSet, set_keys: &[Public]) -> Public {
let mut keys = Vec::new();
for key in set_keys {
@@ -98,33 +112,11 @@ pub fn musig_key(set: ValidatorSet, set_keys: &[Public]) -> Public {
Public(dkg::musig::musig_key::<Ristretto>(&musig_context(set), &keys).unwrap().to_bytes())
}
/// The message for the set_keys signature.
/// The message for the `set_keys` signature.
pub fn set_keys_message(set: &ValidatorSet, key_pair: &KeyPair) -> Vec<u8> {
(b"ValidatorSets-set_keys", set, key_pair).encode()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Encode, Decode, TypeInfo, MaxEncodedLen)]
#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Slash {
#[cfg_attr(
feature = "borsh",
borsh(
serialize_with = "serai_primitives::borsh_serialize_public",
deserialize_with = "serai_primitives::borsh_deserialize_public"
)
)]
pub key: Public,
pub points: u32,
}
#[derive(Clone, PartialEq, Eq, Debug, Encode, Decode, TypeInfo, MaxEncodedLen)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SlashReport(pub BoundedVec<Slash, ConstU32<{ MAX_KEY_SHARES_PER_SET / 3 }>>);
pub fn report_slashes_message(set: &ValidatorSet, slashes: &SlashReport) -> Vec<u8> {
(b"ValidatorSets-report_slashes", set, slashes).encode()
}
/// For a set of validators whose key shares may exceed the maximum, reduce until they equal the
/// maximum.
///

View File

@@ -0,0 +1,258 @@
use core::{num::NonZero, time::Duration};
#[cfg(feature = "std")]
use zeroize::Zeroize;
use scale::{Encode, Decode, MaxEncodedLen};
use scale_info::TypeInfo;
#[cfg(feature = "borsh")]
use borsh::{BorshSerialize, BorshDeserialize};
#[cfg(feature = "serde")]
use serde::{Serialize, Deserialize};
use sp_core::{ConstU32, bounded::BoundedVec};
#[cfg(not(feature = "std"))]
use sp_std::vec::Vec;
use serai_primitives::{TARGET_BLOCK_TIME, Amount};
use crate::{SESSION_LENGTH, MAX_KEY_SHARES_PER_SET_U32};
/// Each slash point is equivalent to the downtime implied by missing a block proposal.
// Takes a NonZero<u16> so that the result is never 0.
fn downtime_per_slash_point(validators: NonZero<u16>) -> Duration {
Duration::from_secs(TARGET_BLOCK_TIME) * u32::from(u16::from(validators))
}
/// A slash for a validator.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Encode, Decode, MaxEncodedLen, TypeInfo)]
#[cfg_attr(feature = "std", derive(Zeroize))]
#[cfg_attr(feature = "borsh", derive(BorshSerialize, BorshDeserialize))]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum Slash {
/// The slash points accumulated by this validator.
///
/// Each point is considered as `downtime_per_slash_point(validators)` downtime, where
/// `validators` is the amount of validators present in the set.
Points(u32),
/// A fatal slash due to fundamentally faulty behavior.
///
/// This should only be used for misbehavior with explicit evidence of impropriety. This should
/// not be used for liveness failures. The validator will be penalized all allocated stake.
Fatal,
}
impl Slash {
/// Calculate the penalty which should be applied to the validator.
///
/// Does not panic, even due to overflows, if `allocated_stake + session_rewards <= u64::MAX`.
pub fn penalty(
self,
validators: NonZero<u16>,
allocated_stake: Amount,
session_rewards: Amount,
) -> Amount {
match self {
Self::Points(slash_points) => {
let mut slash_points = u64::from(slash_points);
// Do the logic with the stake in u128 to prevent overflow from multiplying u64s
let allocated_stake = u128::from(allocated_stake.0);
let session_rewards = u128::from(session_rewards.0);
// A Serai validator is allowed to be offline for an average of one day every two weeks
// with no additional penalty. They'll solely not earn rewards for the time they were
// offline.
const GRACE_WINDOW: Duration = Duration::from_secs(2 * 7 * 24 * 60 * 60);
const GRACE: Duration = Duration::from_secs(24 * 60 * 60);
// GRACE / GRACE_WINDOW is the fraction of the time a validator is allowed to be offline
// This means we want SESSION_LENGTH * (GRACE / GRACE_WINDOW), but with the parentheses
// moved so we don't incur the floordiv and hit 0
const PENALTY_FREE_DOWNTIME: Duration = Duration::from_secs(
(SESSION_LENGTH.as_secs() * GRACE.as_secs()) / GRACE_WINDOW.as_secs(),
);
let downtime_per_slash_point = downtime_per_slash_point(validators);
let penalty_free_slash_points =
PENALTY_FREE_DOWNTIME.as_secs() / downtime_per_slash_point.as_secs();
/*
In practice, the following means:
- Hours 0-12 are penalized as if they're hours 0-12.
- Hours 12-24 are penalized as if they're hours 12-36.
- Hours 24-36 are penalized as if they're hours 36-96.
- Hours 36-48 are penalized as if they're hours 96-168.
- Hours 48-168 are penalized for 0-2% of stake.
- 168-336 hours of slashes, for a session only lasting 168 hours, is penalized for 2-10%
of stake.
This means a validator offline has to be offline for more than two days to start having
their stake slashed.
*/
const MULTIPLIERS: [u64; 4] = [1, 2, 5, 6];
let reward_slash = {
// In intervals of the penalty-free slash points, weight the slash points accrued
// The multiplier for the first interval is 1 as it's penalty-free
let mut weighted_slash_points_for_reward_slash = 0;
let mut total_possible_slash_points_for_rewards_slash = 0;
for mult in MULTIPLIERS {
let slash_points_in_interval = slash_points.min(penalty_free_slash_points);
weighted_slash_points_for_reward_slash += slash_points_in_interval * mult;
total_possible_slash_points_for_rewards_slash += penalty_free_slash_points * mult;
slash_points -= slash_points_in_interval;
}
// If there are no penalty-free slash points, and the validator was slashed, slash the
// entire reward
(u128::from(weighted_slash_points_for_reward_slash) * session_rewards)
.checked_div(u128::from(total_possible_slash_points_for_rewards_slash))
.unwrap_or({
if weighted_slash_points_for_reward_slash == 0 {
0
} else {
session_rewards
}
})
};
let slash_points_for_entire_session =
SESSION_LENGTH.as_secs() / downtime_per_slash_point.as_secs();
let offline_slash = {
// The amount of stake to slash for being offline
const MAX_STAKE_SLASH_PERCENTAGE_OFFLINE: u64 = 2;
let stake_to_slash_for_being_offline =
(allocated_stake * u128::from(MAX_STAKE_SLASH_PERCENTAGE_OFFLINE)) / 100;
// We already removed the slash points for `intervals * penalty_free_slash_points`
let slash_points_for_reward_slash =
penalty_free_slash_points * u64::try_from(MULTIPLIERS.len()).unwrap();
let slash_points_for_offline_stake_slash =
slash_points_for_entire_session.saturating_sub(slash_points_for_reward_slash);
let slash_points_in_interval = slash_points.min(slash_points_for_offline_stake_slash);
slash_points -= slash_points_in_interval;
// If there are no slash points for the entire session, don't slash stake
// That's an extreme edge case which shouldn't start penalizing validators
(u128::from(slash_points_in_interval) * stake_to_slash_for_being_offline)
.checked_div(u128::from(slash_points_for_offline_stake_slash))
.unwrap_or(0)
};
let disruptive_slash = {
/*
A validator may have more slash points than `slash_points_for_stake_slash` if they
didn't just accrue slashes for missing block proposals, yet also accrued slashes for
being disruptive. In that case, we still want to bound their slash points so they can't
somehow be slashed for 100% of their stake (which should only happen on a fatal slash).
*/
const MAX_STAKE_SLASH_PERCENTAGE_DISRUPTIVE: u64 = 8;
let stake_to_slash_for_being_disruptive =
(allocated_stake * u128::from(MAX_STAKE_SLASH_PERCENTAGE_DISRUPTIVE)) / 100;
// Follows the offline slash for `unwrap_or` policy
(u128::from(slash_points.min(slash_points_for_entire_session)) *
stake_to_slash_for_being_disruptive)
.checked_div(u128::from(slash_points_for_entire_session))
.unwrap_or(0)
};
// The penalty is all slashes, but never more than the validator's balance
// (handles any rounding errors which may or may not exist)
let penalty_u128 =
(reward_slash + offline_slash + disruptive_slash).min(allocated_stake + session_rewards);
// saturating_into
Amount(u64::try_from(penalty_u128).unwrap_or(u64::MAX))
}
// On fatal slash, their entire stake is removed
Self::Fatal => Amount(allocated_stake.0 + session_rewards.0),
}
}
}
#[derive(Clone, PartialEq, Eq, Debug, Encode, Decode, TypeInfo, MaxEncodedLen)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SlashReport(pub BoundedVec<Slash, ConstU32<{ MAX_KEY_SHARES_PER_SET_U32 }>>);
// This is assumed binding to the ValidatorSet via the key signed with
pub fn report_slashes_message(slashes: &SlashReport) -> Vec<u8> {
(b"ValidatorSets-report_slashes", slashes).encode()
}
#[test]
fn test_penalty() {
for validators in [1, 50, 100, crate::MAX_KEY_SHARES_PER_SET] {
let validators = NonZero::new(validators).unwrap();
// 12 hours of slash points should only decrease the rewards proportionately
let twelve_hours_of_slash_points =
u32::try_from((12 * 60 * 60) / downtime_per_slash_point(validators).as_secs()).unwrap();
assert_eq!(
Slash::Points(twelve_hours_of_slash_points).penalty(
validators,
Amount(u64::MAX),
Amount(168)
),
Amount(12)
);
// 24 hours of slash points should be counted as 36 hours
assert_eq!(
Slash::Points(2 * twelve_hours_of_slash_points).penalty(
validators,
Amount(u64::MAX),
Amount(168)
),
Amount(36)
);
// 36 hours of slash points should be counted as 96 hours
assert_eq!(
Slash::Points(3 * twelve_hours_of_slash_points).penalty(
validators,
Amount(u64::MAX),
Amount(168)
),
Amount(96)
);
// 48 hours of slash points should be counted as 168 hours
assert_eq!(
Slash::Points(4 * twelve_hours_of_slash_points).penalty(
validators,
Amount(u64::MAX),
Amount(168)
),
Amount(168)
);
// A full week of slash points should slash 2%
let week_of_slash_points = 14 * twelve_hours_of_slash_points;
assert_eq!(
Slash::Points(week_of_slash_points).penalty(validators, Amount(1000), Amount(168)),
Amount(20 + 168)
);
// Two weeks of slash points should slash 10%
assert_eq!(
Slash::Points(2 * week_of_slash_points).penalty(validators, Amount(1000), Amount(168)),
Amount(100 + 168)
);
// Anything greater should still only slash 10%
assert_eq!(
Slash::Points(u32::MAX).penalty(validators, Amount(1000), Amount(168)),
Amount(100 + 168)
);
}
}
#[test]
fn no_overflow() {
Slash::Points(u32::MAX).penalty(
NonZero::new(u16::MAX).unwrap(),
Amount(u64::MAX),
Amount(u64::MAX),
);
Slash::Points(u32::MAX).penalty(NonZero::new(1).unwrap(), Amount(u64::MAX), Amount(u64::MAX));
}