diff --git a/Cargo.lock b/Cargo.lock index 41594a5..6432349 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,6 +31,15 @@ version = "1.0.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "ark-bn254" version = "0.4.0" @@ -538,6 +547,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.58", +] + [[package]] name = "digest" version = "0.9.0" @@ -1340,6 +1360,7 @@ dependencies = [ name = "solana-vote-program" version = "0.1.0" dependencies = [ + "arbitrary", "bincode", "log", "rustc_version", diff --git a/program/Cargo.toml b/program/Cargo.toml index d1952b4..1966494 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -24,6 +24,9 @@ solana-frozen-abi = "1.18.2" solana-program = "1.18.2" spl-program-error = "0.3.1" +[target.'cfg(not(target_os = "solana"))'.dev-dependencies] +arbitrary = { version = "1.3.2", features = ["derive"] } + [lib] crate-type = ["cdylib", "lib"] diff --git a/program/src/lib.rs b/program/src/lib.rs index efa53bd..42ac5e4 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -6,6 +6,7 @@ #[cfg(all(target_os = "solana", feature = "bpf-entrypoint"))] mod entrypoint; pub mod error; +pub mod state; // [Core BPF]: TODO: Program-test will not overwrite existing built-ins. // See https://github.com/solana-labs/solana/pull/35233. diff --git a/program/src/state/circ_buf.rs b/program/src/state/circ_buf.rs new file mode 100644 index 0000000..5373da8 --- /dev/null +++ b/program/src/state/circ_buf.rs @@ -0,0 +1,72 @@ +#[cfg(test)] +use arbitrary::{Arbitrary, Unstructured}; +use { + serde::{Deserialize, Serialize}, + solana_frozen_abi_macro::AbiExample, +}; + +// this is how many epochs a voter can be remembered for slashing +const MAX_ITEMS: usize = 32; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, AbiExample)] +pub struct CircBuf { + buf: [I; MAX_ITEMS], + /// next pointer + idx: usize, + is_empty: bool, +} + +impl Default for CircBuf { + fn default() -> Self { + Self { + buf: [I::default(); MAX_ITEMS], + idx: MAX_ITEMS + .checked_sub(1) + .expect("`MAX_ITEMS` should be positive"), + is_empty: true, + } + } +} + +impl CircBuf { + pub fn append(&mut self, item: I) { + // remember prior delegate and when we switched, to support later slashing + self.idx = self + .idx + .checked_add(1) + .and_then(|idx| idx.checked_rem(MAX_ITEMS)) + .expect("`self.idx` should be < `MAX_ITEMS` which should be non-zero"); + + self.buf[self.idx] = item; + self.is_empty = false; + } + + pub fn buf(&self) -> &[I; MAX_ITEMS] { + &self.buf + } + + pub fn last(&self) -> Option<&I> { + if !self.is_empty { + Some(&self.buf[self.idx]) + } else { + None + } + } +} + +#[cfg(test)] +impl<'a, I: Default + Copy> Arbitrary<'a> for CircBuf +where + I: Arbitrary<'a>, +{ + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + let mut circbuf = Self::default(); + + let len = u.arbitrary_len::()?; + for _ in 0..len { + circbuf.append(I::arbitrary(u)?); + } + + Ok(circbuf) + } +} diff --git a/program/src/state/lockout.rs b/program/src/state/lockout.rs new file mode 100644 index 0000000..8b36dd4 --- /dev/null +++ b/program/src/state/lockout.rs @@ -0,0 +1,96 @@ +#[cfg(test)] +use arbitrary::Arbitrary; +use { + serde::{Deserialize, Serialize}, + solana_frozen_abi_macro::AbiExample, + solana_program::clock::Slot, +}; + +// Maximum number of votes to keep around, tightly coupled with +// epoch_schedule::MINIMUM_SLOTS_PER_EPOCH +pub const MAX_LOCKOUT_HISTORY: usize = 31; +pub const INITIAL_LOCKOUT: usize = 2; + +#[derive(Serialize, Default, Deserialize, Debug, PartialEq, Eq, Copy, Clone, AbiExample)] +#[cfg_attr(test, derive(Arbitrary))] +pub struct Lockout { + slot: Slot, + confirmation_count: u32, +} + +impl Lockout { + pub fn new(slot: Slot) -> Self { + Self::new_with_confirmation_count(slot, 1) + } + + pub fn new_with_confirmation_count(slot: Slot, confirmation_count: u32) -> Self { + Self { + slot, + confirmation_count, + } + } + + // The number of slots for which this vote is locked + pub fn lockout(&self) -> u64 { + (INITIAL_LOCKOUT as u64).pow(self.confirmation_count()) + } + + // The last slot at which a vote is still locked out. Validators should not + // vote on a slot in another fork which is less than or equal to this slot + // to avoid having their stake slashed. + pub fn last_locked_out_slot(&self) -> Slot { + self.slot.saturating_add(self.lockout()) + } + + pub fn is_locked_out_at_slot(&self, slot: Slot) -> bool { + self.last_locked_out_slot() >= slot + } + + pub fn slot(&self) -> Slot { + self.slot + } + + pub fn confirmation_count(&self) -> u32 { + self.confirmation_count + } + + pub fn increase_confirmation_count(&mut self, by: u32) { + self.confirmation_count = self.confirmation_count.saturating_add(by) + } +} + +#[derive(Serialize, Default, Deserialize, Debug, PartialEq, Eq, Copy, Clone, AbiExample)] +#[cfg_attr(test, derive(Arbitrary))] +pub struct LandedVote { + // Latency is the difference in slot number between the slot that was voted on (lockout.slot) + // and the slot in which the vote that added this Lockout landed. For votes which were + // cast before versions of the validator software which recorded vote latencies, latency is + // recorded as 0. + pub latency: u8, + pub lockout: Lockout, +} + +impl LandedVote { + pub fn slot(&self) -> Slot { + self.lockout.slot + } + + pub fn confirmation_count(&self) -> u32 { + self.lockout.confirmation_count + } +} + +impl From for Lockout { + fn from(landed_vote: LandedVote) -> Self { + landed_vote.lockout + } +} + +impl From for LandedVote { + fn from(lockout: Lockout) -> Self { + Self { + latency: 0, + lockout, + } + } +} diff --git a/program/src/state/mod.rs b/program/src/state/mod.rs new file mode 100644 index 0000000..ba0c247 --- /dev/null +++ b/program/src/state/mod.rs @@ -0,0 +1,7 @@ +//! Vote Program state types. + +pub mod circ_buf; +pub mod lockout; +pub mod tower_sync; +pub mod vote; +pub mod vote_state_update; diff --git a/program/src/state/tower_sync.rs b/program/src/state/tower_sync.rs new file mode 100644 index 0000000..555043a --- /dev/null +++ b/program/src/state/tower_sync.rs @@ -0,0 +1,163 @@ +use { + crate::state::lockout::Lockout, + serde::{Deserialize, Serialize}, + solana_frozen_abi_macro::{frozen_abi, AbiExample}, + solana_program::{ + clock::{Slot, UnixTimestamp}, + hash::Hash, + }, + std::collections::VecDeque, +}; + +#[frozen_abi(digest = "5VUusSTenF9vZ9eHiCprVe9ABJUHCubeDNCCDxykybZY")] +#[derive(Serialize, Default, Deserialize, Debug, PartialEq, Eq, Clone, AbiExample)] +pub struct TowerSync { + /// The proposed tower + pub lockouts: VecDeque, + /// The proposed root + pub root: Option, + /// signature of the bank's state at the last slot + pub hash: Hash, + /// processing timestamp of last slot + pub timestamp: Option, + /// the unique identifier for the chain up to and + /// including this block. Does not require replaying + /// in order to compute. + pub block_id: Hash, +} + +impl From> for TowerSync { + fn from(recent_slots: Vec<(Slot, u32)>) -> Self { + let lockouts: VecDeque = recent_slots + .into_iter() + .map(|(slot, confirmation_count)| { + Lockout::new_with_confirmation_count(slot, confirmation_count) + }) + .collect(); + Self { + lockouts, + root: None, + hash: Hash::default(), + timestamp: None, + block_id: Hash::default(), + } + } +} + +impl TowerSync { + pub fn new( + lockouts: VecDeque, + root: Option, + hash: Hash, + block_id: Hash, + ) -> Self { + Self { + lockouts, + root, + hash, + timestamp: None, + block_id, + } + } + + pub fn slots(&self) -> Vec { + self.lockouts.iter().map(|lockout| lockout.slot()).collect() + } + + pub fn last_voted_slot(&self) -> Option { + self.lockouts.back().map(|l| l.slot()) + } +} + +pub mod serde_tower_sync { + use { + super::*, + serde::{Deserialize, Deserializer, Serialize, Serializer}, + solana_program::{serde_varint, short_vec}, + }; + + #[derive(Deserialize, Serialize, AbiExample)] + struct LockoutOffset { + #[serde(with = "serde_varint")] + offset: Slot, + confirmation_count: u8, + } + + #[derive(Deserialize, Serialize)] + struct CompactTowerSync { + root: Slot, + #[serde(with = "short_vec")] + lockout_offsets: Vec, + hash: Hash, + timestamp: Option, + block_id: Hash, + } + + pub fn serialize(tower_sync: &TowerSync, serializer: S) -> Result + where + S: Serializer, + { + let lockout_offsets = tower_sync.lockouts.iter().scan( + tower_sync.root.unwrap_or_default(), + |slot, lockout| { + let Some(offset) = lockout.slot().checked_sub(*slot) else { + return Some(Err(serde::ser::Error::custom("Invalid vote lockout"))); + }; + let Ok(confirmation_count) = u8::try_from(lockout.confirmation_count()) else { + return Some(Err(serde::ser::Error::custom("Invalid confirmation count"))); + }; + let lockout_offset = LockoutOffset { + offset, + confirmation_count, + }; + *slot = lockout.slot(); + Some(Ok(lockout_offset)) + }, + ); + let compact_tower_sync = CompactTowerSync { + root: tower_sync.root.unwrap_or(Slot::MAX), + lockout_offsets: lockout_offsets.collect::>()?, + hash: tower_sync.hash, + timestamp: tower_sync.timestamp, + block_id: tower_sync.block_id, + }; + compact_tower_sync.serialize(serializer) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let CompactTowerSync { + root, + lockout_offsets, + hash, + timestamp, + block_id, + } = CompactTowerSync::deserialize(deserializer)?; + let root = (root != Slot::MAX).then_some(root); + let lockouts = + lockout_offsets + .iter() + .scan(root.unwrap_or_default(), |slot, lockout_offset| { + *slot = match slot.checked_add(lockout_offset.offset) { + None => { + return Some(Err(serde::de::Error::custom("Invalid lockout offset"))) + } + Some(slot) => slot, + }; + let lockout = Lockout::new_with_confirmation_count( + *slot, + u32::from(lockout_offset.confirmation_count), + ); + Some(Ok(lockout)) + }); + Ok(TowerSync { + root, + lockouts: lockouts.collect::>()?, + hash, + timestamp, + block_id, + }) + } +} diff --git a/program/src/state/vote.rs b/program/src/state/vote.rs new file mode 100644 index 0000000..1667cfa --- /dev/null +++ b/program/src/state/vote.rs @@ -0,0 +1,33 @@ +use { + serde::{Deserialize, Serialize}, + solana_frozen_abi_macro::{frozen_abi, AbiExample}, + solana_program::{ + clock::{Slot, UnixTimestamp}, + hash::Hash, + }, +}; + +#[frozen_abi(digest = "Ch2vVEwos2EjAVqSHCyJjnN2MNX1yrpapZTGhMSCjWUH")] +#[derive(Serialize, Default, Deserialize, Debug, PartialEq, Eq, Clone, AbiExample)] +pub struct Vote { + /// A stack of votes starting with the oldest vote + pub slots: Vec, + /// signature of the bank's state at the last slot + pub hash: Hash, + /// processing timestamp of last slot + pub timestamp: Option, +} + +impl Vote { + pub fn new(slots: Vec, hash: Hash) -> Self { + Self { + slots, + hash, + timestamp: None, + } + } + + pub fn last_voted_slot(&self) -> Option { + self.slots.last().copied() + } +} diff --git a/program/src/state/vote_state_update.rs b/program/src/state/vote_state_update.rs new file mode 100644 index 0000000..0178a16 --- /dev/null +++ b/program/src/state/vote_state_update.rs @@ -0,0 +1,151 @@ +use { + crate::state::lockout::Lockout, + serde::{Deserialize, Serialize}, + solana_frozen_abi_macro::{frozen_abi, AbiExample}, + solana_program::{ + clock::{Slot, UnixTimestamp}, + hash::Hash, + }, + std::collections::VecDeque, +}; + +#[frozen_abi(digest = "GwJfVFsATSj7nvKwtUkHYzqPRaPY6SLxPGXApuCya3x5")] +#[derive(Serialize, Default, Deserialize, Debug, PartialEq, Eq, Clone, AbiExample)] +pub struct VoteStateUpdate { + /// The proposed tower + pub lockouts: VecDeque, + /// The proposed root + pub root: Option, + /// signature of the bank's state at the last slot + pub hash: Hash, + /// processing timestamp of last slot + pub timestamp: Option, +} + +impl From> for VoteStateUpdate { + fn from(recent_slots: Vec<(Slot, u32)>) -> Self { + let lockouts: VecDeque = recent_slots + .into_iter() + .map(|(slot, confirmation_count)| { + Lockout::new_with_confirmation_count(slot, confirmation_count) + }) + .collect(); + Self { + lockouts, + root: None, + hash: Hash::default(), + timestamp: None, + } + } +} + +impl VoteStateUpdate { + pub fn new(lockouts: VecDeque, root: Option, hash: Hash) -> Self { + Self { + lockouts, + root, + hash, + timestamp: None, + } + } + + pub fn slots(&self) -> Vec { + self.lockouts.iter().map(|lockout| lockout.slot()).collect() + } + + pub fn last_voted_slot(&self) -> Option { + self.lockouts.back().map(|l| l.slot()) + } +} + +pub mod serde_compact_vote_state_update { + use { + super::*, + serde::{Deserialize, Deserializer, Serialize, Serializer}, + solana_program::{serde_varint, short_vec}, + }; + + #[derive(Deserialize, Serialize, AbiExample)] + struct LockoutOffset { + #[serde(with = "serde_varint")] + offset: Slot, + confirmation_count: u8, + } + + #[derive(Deserialize, Serialize)] + struct CompactVoteStateUpdate { + root: Slot, + #[serde(with = "short_vec")] + lockout_offsets: Vec, + hash: Hash, + timestamp: Option, + } + + pub fn serialize( + vote_state_update: &VoteStateUpdate, + serializer: S, + ) -> Result + where + S: Serializer, + { + let lockout_offsets = vote_state_update.lockouts.iter().scan( + vote_state_update.root.unwrap_or_default(), + |slot, lockout| { + let Some(offset) = lockout.slot().checked_sub(*slot) else { + return Some(Err(serde::ser::Error::custom("Invalid vote lockout"))); + }; + let Ok(confirmation_count) = u8::try_from(lockout.confirmation_count()) else { + return Some(Err(serde::ser::Error::custom("Invalid confirmation count"))); + }; + let lockout_offset = LockoutOffset { + offset, + confirmation_count, + }; + *slot = lockout.slot(); + Some(Ok(lockout_offset)) + }, + ); + let compact_vote_state_update = CompactVoteStateUpdate { + root: vote_state_update.root.unwrap_or(Slot::MAX), + lockout_offsets: lockout_offsets.collect::>()?, + hash: vote_state_update.hash, + timestamp: vote_state_update.timestamp, + }; + compact_vote_state_update.serialize(serializer) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let CompactVoteStateUpdate { + root, + lockout_offsets, + hash, + timestamp, + } = CompactVoteStateUpdate::deserialize(deserializer)?; + let root = (root != Slot::MAX).then_some(root); + let lockouts = + lockout_offsets + .iter() + .scan(root.unwrap_or_default(), |slot, lockout_offset| { + *slot = match slot.checked_add(lockout_offset.offset) { + None => { + return Some(Err(serde::de::Error::custom("Invalid lockout offset"))) + } + Some(slot) => slot, + }; + let lockout = Lockout::new_with_confirmation_count( + *slot, + u32::from(lockout_offset.confirmation_count), + ); + Some(Ok(lockout)) + }); + Ok(VoteStateUpdate { + root, + lockouts: lockouts.collect::>()?, + hash, + timestamp, + }) + } +}