diff --git a/Cargo.toml b/Cargo.toml index 6fdd5c7e..b579123f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,8 @@ [workspace] members = [ - # "common", + "common", + "consensus", #"cuprate", # "database", "net/levin", diff --git a/common/src/lib.rs b/common/src/lib.rs index 14fa114b..f6f9426e 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,8 +1,8 @@ -pub mod hardforks; +//pub mod hardforks; pub mod network; pub mod pruning; -pub use hardforks::HardForks; +//pub use hardforks::HardForks; pub use network::Network; pub use pruning::{PruningError, PruningSeed}; @@ -12,3 +12,15 @@ pub const CRYPTONOTE_MAX_BLOCK_NUMBER: u64 = 500000000; pub const CRYPTONOTE_PRUNING_LOG_STRIPES: u32 = 3; pub const CRYPTONOTE_PRUNING_STRIPE_SIZE: u64 = 4096; pub const CRYPTONOTE_PRUNING_TIP_BLOCKS: u64 = 5500; + +#[derive(Debug)] +pub enum BlockID { + Hash([u8; 32]), + Height(u64), +} + +impl From for BlockID { + fn from(value: u64) -> Self { + BlockID::Height(value) + } +} diff --git a/common/src/network.rs b/common/src/network.rs index 0e6c4e1e..85891f97 100644 --- a/common/src/network.rs +++ b/common/src/network.rs @@ -10,17 +10,17 @@ const STAGENET_NETWORK_ID: [u8; 16] = [ #[derive(Debug, Clone, Copy)] pub enum Network { - MainNet, - TestNet, - StageNet, + Mainnet, + Testnet, + Stagenet, } impl Network { pub fn network_id(&self) -> [u8; 16] { match self { - Network::MainNet => MAINNET_NETWORK_ID, - Network::TestNet => TESTNET_NETWORK_ID, - Network::StageNet => STAGENET_NETWORK_ID, + Network::Mainnet => MAINNET_NETWORK_ID, + Network::Testnet => TESTNET_NETWORK_ID, + Network::Stagenet => STAGENET_NETWORK_ID, } } } diff --git a/consensus/Cargo.toml b/consensus/Cargo.toml new file mode 100644 index 00000000..f321517a --- /dev/null +++ b/consensus/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "monero-consensus" +version = "0.1.0" +edition = "2021" +description = "A crate implimenting all Moneros consensus rules." +license = "MIT" +authors = ["Boog900"] +repository = "https://github.com/Cuprate/cuprate/tree/main/consensus" + +[features] +default = ["binaries"] +binaries = ["rpc", "dep:tokio", "dep:tracing-subscriber", "tower/retry", "tower/balance"] +rpc = ["dep:futures"] + +[dependencies] +hex = "0.4" +thiserror = "1" +tower = {version = "0.4", features = ["util"]} +tracing = "0.1" + +monero-serai = {git="https://github.com/Cuprate/serai.git", rev = "84b77b1"} + +cuprate-common = {path = "../common"} + +# used for rpc +futures = {version = "0.3", optional = true} +# used in binaries +tokio = { version = "1", features = ["rt-multi-thread", "macros"], optional = true } +tracing-subscriber = {version = "0.3", optional = true} +# here to help cargo to pick a version - remove me +syn = "2.0.29" \ No newline at end of file diff --git a/consensus/src/bin/scan_chain.rs b/consensus/src/bin/scan_chain.rs new file mode 100644 index 00000000..5cc269b4 --- /dev/null +++ b/consensus/src/bin/scan_chain.rs @@ -0,0 +1,39 @@ +#![cfg(feature = "binaries")] + +use tower::{Service, ServiceExt}; +use tracing::level_filters::LevelFilter; + +use monero_consensus::hardforks::{HardFork, HardForkConfig, HardForks}; +use monero_consensus::rpc::Rpc; +use monero_consensus::DatabaseRequest; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_max_level(LevelFilter::INFO) + .init(); + + let mut rpc = Rpc::new_http("http://xmr-node.cakewallet.com:18081".to_string()); + + let res = rpc + .ready() + .await + .unwrap() + .call(DatabaseRequest::ChainHeight) + .await + .unwrap(); + + println!("{:?}", res); + + let mut hfs = HardForks::init(HardForkConfig::default(), &mut rpc) + .await + .unwrap(); + + println!("{:?}", hfs); + + hfs.new_block(HardFork::V2, 1009827, &mut rpc).await; + println!("{:?}", hfs); + + hfs.new_block(HardFork::V2, 1009828, &mut rpc).await; + println!("{:?}", hfs); +} diff --git a/consensus/src/genesis.rs b/consensus/src/genesis.rs new file mode 100644 index 00000000..7b7bd7ca --- /dev/null +++ b/consensus/src/genesis.rs @@ -0,0 +1,70 @@ +/// This module contains the code to generate Monero's genesis blocks. +/// +/// ref: consensus-doc#Genesis +use monero_serai::{ + block::{Block, BlockHeader}, + transaction::Transaction, +}; + +use cuprate_common::Network; + +fn genesis_nonce(network: &Network) -> u32 { + match network { + Network::Mainnet => 10000, + Network::Testnet => 10001, + Network::Stagenet => 10002, + } +} + +fn genesis_miner_tx(network: &Network) -> Transaction { + Transaction::read(&mut hex::decode(match network { + Network::Mainnet | Network::Testnet => "013c01ff0001ffffffffffff03029b2e4c0281c0b02e7c53291a94d1d0cbff8883f8024f5142ee494ffbbd08807121017767aafcde9be00dcfd098715ebcf7f410daebc582fda69d24a28e9d0bc890d1", + Network::Stagenet => "013c01ff0001ffffffffffff0302df5d56da0c7d643ddd1ce61901c7bdc5fb1738bfe39fbe69c28a3a7032729c0f2101168d0c4ca86fb55a4cf6a36d31431be1c53a3bd7411bb24e8832410289fa6f3b" + }).unwrap().as_slice()).unwrap() +} + +/// Generates the Monero genesis block. +/// +/// ref: consensus-doc#Genesis +pub fn generate_genesis_block(network: &Network) -> Block { + Block { + header: BlockHeader { + major_version: 1, + minor_version: 0, + timestamp: 0, + previous: [0; 32], + nonce: genesis_nonce(network), + }, + miner_tx: genesis_miner_tx(network), + txs: vec![], + } +} + +#[cfg(test)] +mod tests { + use cuprate_common::Network; + + use super::generate_genesis_block; + + #[test] + fn generate_genesis_blocks() { + assert_eq!( + &generate_genesis_block(&Network::Mainnet).hash(), + hex::decode("418015bb9ae982a1975da7d79277c2705727a56894ba0fb246adaabb1f4632e3") + .unwrap() + .as_slice() + ); + assert_eq!( + &generate_genesis_block(&Network::Testnet).hash(), + hex::decode("48ca7cd3c8de5b6a4d53d2861fbdaedca141553559f9be9520068053cda8430b") + .unwrap() + .as_slice() + ); + assert_eq!( + &generate_genesis_block(&Network::Stagenet).hash(), + hex::decode("76ee3cc98646292206cd3e86f74d88b4dcc1d937088645e9b0cbca84b7ce74eb") + .unwrap() + .as_slice() + ); + } +} diff --git a/consensus/src/hardforks.rs b/consensus/src/hardforks.rs new file mode 100644 index 00000000..e2d9a65e --- /dev/null +++ b/consensus/src/hardforks.rs @@ -0,0 +1,342 @@ +use std::ops::Range; + +use monero_serai::block::BlockHeader; +use tower::ServiceExt; +use tracing::instrument; + +use cuprate_common::{BlockID, Network}; + +use crate::{Database, DatabaseRequest, DatabaseResponse, Error}; + +//http://localhost:3000/consensus_rules/hardforks.html#window-size +const DEFAULT_WINDOW_SIZE: u64 = 10080; // supermajority window check length - a week + +/// An identifier for every hard-fork Monero has had. +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Clone)] +#[repr(u8)] +pub enum HardFork { + V1 = 1, + V2, + V3, + V4, + V5, + V6, + V7, + V8, + V9, + V10, + V11, + V12, + V13, + V14, + V15, + V16, +} + +impl HardFork { + /// Returns the hard-fork for a blocks `major_version` field. + /// + /// http://**/consensus_rules/hardforks.html#blocks-version-and-vote + pub fn from_version(version: &u8) -> Result { + Ok(match version { + 1 => HardFork::V1, + 2 => HardFork::V2, + 3 => HardFork::V3, + 4 => HardFork::V4, + 5 => HardFork::V5, + 6 => HardFork::V6, + 7 => HardFork::V7, + 8 => HardFork::V8, + 9 => HardFork::V9, + 10 => HardFork::V10, + 11 => HardFork::V11, + 12 => HardFork::V12, + 13 => HardFork::V13, + 14 => HardFork::V14, + 15 => HardFork::V15, + 16 => HardFork::V16, + _ => { + return Err(Error::InvalidHardForkVersion( + "Version is not a known hard fork", + )) + } + }) + } + + /// Returns the hard-fork for a blocks `minor_version` (vote) field. + /// + /// http://**/consensus_rules/hardforks.html#blocks-version-and-vote + pub fn from_vote(vote: &u8) -> HardFork { + if *vote == 0 { + // A vote of 0 is interpreted as 1 as that's what Monero used to default to. + return HardFork::V1; + } + // This must default to the latest hard-fork! + Self::from_version(vote).unwrap_or(HardFork::V16) + } + + /// Returns the next hard-fork. + pub fn next_fork(&self) -> Option { + match self { + HardFork::V1 => Some(HardFork::V2), + HardFork::V2 => Some(HardFork::V3), + HardFork::V3 => Some(HardFork::V4), + HardFork::V4 => Some(HardFork::V5), + HardFork::V5 => Some(HardFork::V6), + HardFork::V6 => Some(HardFork::V7), + HardFork::V7 => Some(HardFork::V8), + HardFork::V8 => Some(HardFork::V9), + HardFork::V9 => Some(HardFork::V10), + HardFork::V10 => Some(HardFork::V11), + HardFork::V11 => Some(HardFork::V12), + HardFork::V12 => Some(HardFork::V13), + HardFork::V13 => Some(HardFork::V14), + HardFork::V14 => Some(HardFork::V15), + HardFork::V15 => Some(HardFork::V16), + HardFork::V16 => None, + } + } + + /// Returns the threshold of this fork. + pub fn fork_threshold(&self, _: &Network) -> u64 { + 0 + } + + /// Returns the votes needed for this fork. + pub fn votes_needed(&self, network: &Network, window: u64) -> u64 { + (self.fork_threshold(network) * window + 99) / 100 + } + + /// Returns the minimum height this fork will activate at + pub fn fork_height(&self, network: &Network) -> u64 { + match network { + Network::Mainnet => self.mainnet_fork_height(), + Network::Stagenet => self.stagenet_fork_height(), + Network::Testnet => self.testnet_fork_height(), + } + } + + fn stagenet_fork_height(&self) -> u64 { + todo!() + } + + fn testnet_fork_height(&self) -> u64 { + todo!() + } + + fn mainnet_fork_height(&self) -> u64 { + match self { + HardFork::V1 => 0, // Monero core has this as 1, which is strange + HardFork::V2 => 1009827, + HardFork::V3 => 1141317, + HardFork::V4 => 1220516, + HardFork::V5 => 1288616, + HardFork::V6 => 1400000, + HardFork::V7 => 1546000, + HardFork::V8 => 1685555, + HardFork::V9 => 1686275, + HardFork::V10 => 1788000, + HardFork::V11 => 1788720, + HardFork::V12 => 1978433, + HardFork::V13 => 2210000, + HardFork::V14 => 2210720, + HardFork::V15 => 2688888, + HardFork::V16 => 2689608, + } + } +} + +/// A struct holding the current voting state of the blockchain. +#[derive(Debug, Default)] +struct HFVotes { + votes: [u64; 16], +} + +impl HFVotes { + /// Add votes for a hard-fork + pub fn add_votes_for_hf(&mut self, hf: &HardFork, votes: u64) { + self.votes[*hf as usize - 1] += votes; + } + + /// Add a vote for a hard-fork. + pub fn add_vote_for_hf(&mut self, hf: &HardFork) { + self.add_votes_for_hf(hf, 1) + } + + /// Remove a vote for a hard-fork. + pub fn remove_vote_for_hf(&mut self, hf: &HardFork) { + self.votes[*hf as usize - 1] -= 1; + } + + /// Returns the total votes for a hard-fork. + /// + /// http://localhost:3000/consensus_rules/hardforks.html#accepting-a-fork + pub fn get_votes_for_hf(&self, hf: &HardFork) -> u64 { + self.votes[*hf as usize - 1..].iter().sum() + } + + /// Returns the total amount of votes being tracked + pub fn total_votes(&self) -> u64 { + self.votes.iter().sum() + } +} + +/// Configuration for hard-forks. +/// +#[derive(Debug)] +pub struct HardForkConfig { + /// The network we are on. + network: Network, + /// The amount of votes we are taking into account to decide on a fork activation. + window: u64, +} + +impl Default for HardForkConfig { + fn default() -> Self { + Self { + network: Network::Mainnet, + window: 3, //DEFAULT_WINDOW_SIZE, + } + } +} + +/// A struct that keeps track of the current hard-fork and current votes. +#[derive(Debug)] +pub struct HardForks { + current_hardfork: HardFork, + next_hardfork: Option, + + config: HardForkConfig, + votes: HFVotes, + + last_height: u64, +} + +impl HardForks { + pub async fn init(config: HardForkConfig, database: &mut D) -> Result + where + D: Database, + { + let DatabaseResponse::ChainHeight(chain_height) = database + .ready() + .await? + .call(DatabaseRequest::ChainHeight) + .await? else { + panic!("Database sent incorrect response") + }; + + let block_heights = if chain_height > config.window { + chain_height - config.window..chain_height + } else { + 0..chain_height + }; + + let votes = get_votes_in_range(database, block_heights).await?; + + if chain_height > config.window { + assert_eq!(votes.total_votes(), config.window) + } + + let latest_header = get_block_header(database, chain_height - 1).await?; + + let current_hardfork = HardFork::from_version(&latest_header.major_version) + .expect("Invalid major version in stored block"); + + let next_hardfork = current_hardfork.next_fork(); + + let mut hfs = HardForks { + config, + current_hardfork, + next_hardfork, + votes, + last_height: chain_height - 1, + }; + + // chain_height = height + 1 + hfs.check_set_new_hf(chain_height); + + Ok(hfs) + } + + pub fn check_block_version_vote(&self, version: &HardFork, vote: &HardFork) -> bool { + &self.current_hardfork == version && vote >= &self.current_hardfork + } + + pub async fn new_block(&mut self, vote: HardFork, height: u64, database: &mut D) { + assert_eq!(self.last_height + 1, height); + self.last_height += 1; + + self.votes.add_vote_for_hf(&vote); + + for offset in self.config.window..self.votes.total_votes() { + let header = get_block_header(database, height - offset) + .await + .expect("Error retrieving block we should have in database"); + self.votes + .remove_vote_for_hf(&HardFork::from_vote(&header.minor_version)); + } + + if height > self.config.window { + assert_eq!(self.votes.total_votes(), self.config.window); + } + + self.check_set_new_hf(height + 1) + } + + fn check_set_new_hf(&mut self, height: u64) { + while let Some(new_hf) = self.next_hardfork { + if height >= new_hf.fork_height(&self.config.network) + && self.votes.get_votes_for_hf(&new_hf) + >= new_hf.votes_needed(&self.config.network, self.config.window) + { + self.set_hf(new_hf); + } else { + return; + } + } + } + + fn set_hf(&mut self, new_hf: HardFork) { + self.next_hardfork = new_hf.next_fork(); + self.current_hardfork = new_hf; + } +} + +#[instrument(skip(database))] +async fn get_votes_in_range( + database: &mut D, + block_heights: Range, +) -> Result { + let mut votes = HFVotes::default(); + + for height in block_heights { + let header = get_block_header(database, height).await?; + + let vote = HardFork::from_vote(&header.minor_version); + + tracing::info!("Block vote for height: {} = {:?}", height, vote); + + votes.add_vote_for_hf(&HardFork::from_vote(&header.minor_version)); + } + + Ok(votes) +} + +async fn get_block_header( + database: &mut D, + block_id: impl Into, +) -> Result { + let DatabaseResponse::BlockHeader(header) = database + .oneshot(DatabaseRequest::BlockHeader(block_id.into())) + .await? else { + panic!("Database sent incorrect response for block header request") + }; + Ok(header) +} + +#[test] +fn to_from_hf() { + let hf = HardFork::V1 as u8; + + assert_eq!(hf, 1) +} diff --git a/consensus/src/lib.rs b/consensus/src/lib.rs new file mode 100644 index 00000000..4b24ce8e --- /dev/null +++ b/consensus/src/lib.rs @@ -0,0 +1,36 @@ +use tower::ServiceExt; + +pub mod genesis; +pub mod hardforks; +#[cfg(feature = "rpc")] +pub mod rpc; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Invalid hard fork version: {0}")] + InvalidHardForkVersion(&'static str), + #[error("Database error: {0}")] + Database(#[from] tower::BoxError), +} + +pub trait Database: + tower::Service +{ +} + +impl> + Database for T +{ +} + +#[derive(Debug)] +pub enum DatabaseRequest { + BlockHeader(cuprate_common::BlockID), + ChainHeight, +} + +#[derive(Debug)] +pub enum DatabaseResponse { + BlockHeader(monero_serai::block::BlockHeader), + ChainHeight(u64), +} diff --git a/consensus/src/rpc.rs b/consensus/src/rpc.rs new file mode 100644 index 00000000..61bebfa7 --- /dev/null +++ b/consensus/src/rpc.rs @@ -0,0 +1,89 @@ +use futures::lock::{OwnedMutexGuard, OwnedMutexLockFuture}; +use futures::{FutureExt, TryFutureExt}; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use monero_serai::rpc::{HttpRpc, RpcConnection}; + +use cuprate_common::BlockID; + +use crate::{DatabaseRequest, DatabaseResponse}; + +enum RpcState { + Locked, + Acquiring(OwnedMutexLockFuture>), + Acquired(OwnedMutexGuard>), +} +pub struct Rpc( + Arc>>, + RpcState, +); + +impl Rpc { + pub fn new_http(addr: String) -> Rpc { + let http_rpc = HttpRpc::new(addr).unwrap(); + Rpc( + Arc::new(futures::lock::Mutex::new(http_rpc)), + RpcState::Locked, + ) + } +} + +impl Clone for Rpc { + fn clone(&self) -> Self { + Rpc(Arc::clone(&self.0), RpcState::Locked) + } +} + +impl tower::Service for Rpc { + type Response = DatabaseResponse; + type Error = tower::BoxError; + type Future = Pin> + 'static>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + loop { + match &mut self.1 { + RpcState::Locked => self.1 = RpcState::Acquiring(self.0.clone().lock_owned()), + RpcState::Acquiring(rpc) => { + self.1 = RpcState::Acquired(futures::ready!(rpc.poll_unpin(cx))) + } + RpcState::Acquired(_) => return Poll::Ready(Ok(())), + } + } + } + + fn call(&mut self, req: DatabaseRequest) -> Self::Future { + let RpcState::Acquired(rpc) = std::mem::replace(&mut self.1, RpcState::Locked) else { + panic!("poll_ready was not called first!"); + }; + + match req { + DatabaseRequest::ChainHeight => async move { + rpc.get_height() + .map_ok(|height| DatabaseResponse::ChainHeight(height.try_into().unwrap())) + .map_err(Into::into) + .await + } + .boxed(), + + DatabaseRequest::BlockHeader(id) => match id { + BlockID::Hash(hash) => async move { + rpc.get_block(hash) + .map_ok(|block| DatabaseResponse::BlockHeader(block.header)) + .map_err(Into::into) + .await + } + .boxed(), + BlockID::Height(height) => async move { + rpc.get_block_by_number(height.try_into().unwrap()) + .map_ok(|block| DatabaseResponse::BlockHeader(block.header)) + .map_err(Into::into) + .await + } + .boxed(), + }, + } + } +}