Split consensus rules into separate crate.

This commit is contained in:
Boog900 2023-12-14 15:39:16 +00:00
parent fbd324c45d
commit 3eea0b73bd
No known key found for this signature in database
GPG key ID: 5401367FB7302004
12 changed files with 1160 additions and 5 deletions

View file

@ -4,6 +4,7 @@ resolver = "2"
members = [
"common",
"consensus",
"consensus/rules",
"cryptonight",
# "cuprate",
# "database",
@ -39,14 +40,14 @@ bytes = { version = "1.5.0" }
clap = { version = "4.4.7" }
chrono = { version = "0.4.31" }
crypto-bigint = { version = "0.5.3" }
curve25519-dalek = { version = "4.11" }
dalek-ff-group = { git = "https://github.com/Cuprate/serai.git", rev = "39eafae" }
curve25519-dalek = { version = "4.1.1" }
dalek-ff-group = { git = "https://github.com/Cuprate/serai.git", rev = "77edd00" }
dirs = { version = "5.0.1" }
futures = { version = "0.3.29" }
hex = { version = "0.4.3" }
monero-epee-bin-serde = { git = "https://github.com/monero-rs/monero-epee-bin-serde.git", rev = "e4a585a" }
monero-serai = { git = "https://github.com/Cuprate/serai.git", rev = "39eafae" }
multiexp = { git = "https://github.com/Cuprate/serai.git", rev = "39eafae" }
monero-serai = { git = "https://github.com/Cuprate/serai.git", rev = "77edd00" }
multiexp = { git = "https://github.com/Cuprate/serai.git", rev = "77edd00" }
randomx-rs = { version = "1.2.1" }
rand = { version = "0.8.5" }
rayon = { version = "1.8.0" }
@ -60,6 +61,11 @@ tower = { version = "0.4.13", features = ["util", "steer"] }
tracing-subscriber = { version = "0.3.17" }
tracing = { version = "0.1.40" }
## workspace.dev-dependencies
proptest = { version = "1" }
proptest-derive = { version = "0.4.0" }
## TODO:
## Potential dependencies.
# arc-swap = { version = "1.6.0" } # Atomically swappable Arc<T> | https://github.com/vorner/arc-swap

View file

@ -1,5 +1,5 @@
[package]
name = "monero-consensus"
name = "cuprate-consensus"
version = "0.1.0"
edition = "2021"
description = "A crate implimenting all Moneros consensus rules."

10
consensus/README.md Normal file
View file

@ -0,0 +1,10 @@
# Consensus Rules
This folder contains 2 crates: `monero-consensus` (rules) and `cuprate-consensus`. `monero-consensus` contains the raw-rules
and isb built to be a more flexible library which requires the user to give the correct data and do minimal calculations, `cuprate-consensus`
on the other hand contains multiple tower::Services that handle tx/ block verification as a whole with a `context` service that
keeps track of blockchain state. `cuprate-consensus` uses `monero-consensus` internally.
If you are looking to use monero consensus rules it's recommended you try to integrate `cuprate-consensus` and fall back to
`monero-consensus` if you need more flexibility.

View file

@ -0,0 +1,24 @@
[package]
name = "monero-consensus"
version = "0.1.0"
edition = "2021"
[features]
default = []
proptest = ["dep:proptest", "dep:proptest-derive"]
rayon = ["dep:rayon"]
[dependencies]
monero-serai = { workspace = true }
curve25519-dalek = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
rayon = { workspace = true, optional = true }
# Testing
proptest = {workspace = true, optional = true}
proptest-derive = {workspace = true, optional = true}

View file

@ -0,0 +1,63 @@
use std::sync::OnceLock;
/// Decomposed amount table.
///
static DECOMPOSED_AMOUNTS: OnceLock<[u64; 172]> = OnceLock::new();
#[rustfmt::skip]
pub fn decomposed_amounts() -> &'static [u64; 172] {
DECOMPOSED_AMOUNTS.get_or_init(|| {
[
1, 2, 3, 4, 5, 6, 7, 8, 9,
10, 20, 30, 40, 50, 60, 70, 80, 90,
100, 200, 300, 400, 500, 600, 700, 800, 900,
1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000,
10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000,
100000, 200000, 300000, 400000, 500000, 600000, 700000, 800000, 900000,
1000000, 2000000, 3000000, 4000000, 5000000, 6000000, 7000000, 8000000, 9000000,
10000000, 20000000, 30000000, 40000000, 50000000, 60000000, 70000000, 80000000, 90000000,
100000000, 200000000, 300000000, 400000000, 500000000, 600000000, 700000000, 800000000, 900000000,
1000000000, 2000000000, 3000000000, 4000000000, 5000000000, 6000000000, 7000000000, 8000000000, 9000000000,
10000000000, 20000000000, 30000000000, 40000000000, 50000000000, 60000000000, 70000000000, 80000000000, 90000000000,
100000000000, 200000000000, 300000000000, 400000000000, 500000000000, 600000000000, 700000000000, 800000000000, 900000000000,
1000000000000, 2000000000000, 3000000000000, 4000000000000, 5000000000000, 6000000000000, 7000000000000, 8000000000000, 9000000000000,
10000000000000, 20000000000000, 30000000000000, 40000000000000, 50000000000000, 60000000000000, 70000000000000, 80000000000000, 90000000000000,
100000000000000, 200000000000000, 300000000000000, 400000000000000, 500000000000000, 600000000000000, 700000000000000, 800000000000000, 900000000000000,
1000000000000000, 2000000000000000, 3000000000000000, 4000000000000000, 5000000000000000, 6000000000000000, 7000000000000000, 8000000000000000, 9000000000000000,
10000000000000000, 20000000000000000, 30000000000000000, 40000000000000000, 50000000000000000, 60000000000000000, 70000000000000000, 80000000000000000, 90000000000000000,
100000000000000000, 200000000000000000, 300000000000000000, 400000000000000000, 500000000000000000, 600000000000000000, 700000000000000000, 800000000000000000, 900000000000000000,
1000000000000000000, 2000000000000000000, 3000000000000000000, 4000000000000000000, 5000000000000000000, 6000000000000000000, 7000000000000000000, 8000000000000000000, 9000000000000000000,
10000000000000000000]
})
}
/// Checks that an output amount is decomposed.
///
/// This is also used during miner tx verification.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#output-amount
/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#output-amounts
#[inline]
pub fn is_decomposed_amount(amount: &u64) -> bool {
decomposed_amounts().binary_search(amount).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decomposed_amounts_return_decomposed() {
for amount in decomposed_amounts() {
assert!(is_decomposed_amount(amount))
}
}
#[test]
fn decomposed_amounts_return_not_decomposed() {
assert!(!is_decomposed_amount(&21));
assert!(!is_decomposed_amount(&345431));
assert!(!is_decomposed_amount(&20000001));
}
}

View file

@ -0,0 +1,270 @@
//! # Hard-Forks
//!
//! Monero use hard-forks to update it's protocol, this module contains a [`HardFork`] enum which is
//! an identifier for every current hard-fork.
//!
//! This module also contains a [`HFVotes`] struct which keeps track of current blockchain voting, and
//! has a method [`HFVotes::check_next_hard_fork`] to check if the next hard-fork should be activated.
//!
use std::{
collections::VecDeque,
fmt::{Display, Formatter},
time::Duration,
};
/// Target block time for hf 1.
const BLOCK_TIME_V1: Duration = Duration::from_secs(60);
/// Target block time from v2.
const BLOCK_TIME_V2: Duration = Duration::from_secs(120);
const NUMB_OF_HARD_FORKS: usize = 16;
#[derive(Debug, Copy, Clone, PartialEq, Eq, thiserror::Error)]
pub enum HardForkError {
#[error("The hard-fork is unknown")]
HardForkUnknown,
#[error("The block is on an incorrect hard-fork")]
VersionIncorrect,
#[error("The block's vote is for a previous hard-fork")]
VoteTooLow,
}
/// Information about a given hard-fork.
#[derive(Debug, Clone, Copy)]
pub struct HFInfo {
height: u64,
threshold: u64,
}
impl HFInfo {
pub const fn new(height: u64, threshold: u64) -> HFInfo {
HFInfo { height, threshold }
}
}
/// Information about every hard-fork Monero has had.
#[derive(Debug, Clone, Copy)]
pub struct HFsInfo([HFInfo; NUMB_OF_HARD_FORKS]);
impl HFsInfo {
pub fn info_for_hf(&self, hf: &HardFork) -> HFInfo {
self.0[*hf as usize - 1]
}
/// Returns the main-net hard-fork information.
///
/// https://cuprate.github.io/monero-book/consensus_rules/hardforks.html#Mainnet-Hard-Forks
pub const fn main_net() -> HFsInfo {
Self([
HFInfo::new(0, 0),
HFInfo::new(1009827, 0),
HFInfo::new(1141317, 0),
HFInfo::new(1220516, 0),
HFInfo::new(1288616, 0),
HFInfo::new(1400000, 0),
HFInfo::new(1546000, 0),
HFInfo::new(1685555, 0),
HFInfo::new(1686275, 0),
HFInfo::new(1788000, 0),
HFInfo::new(1788720, 0),
HFInfo::new(1978433, 0),
HFInfo::new(2210000, 0),
HFInfo::new(2210720, 0),
HFInfo::new(2688888, 0),
HFInfo::new(2689608, 0),
])
}
}
/// An identifier for every hard-fork Monero has had.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Clone)]
#[cfg_attr(proptest, derive(proptest_derive::Arbitrary))]
#[repr(u8)]
pub enum HardFork {
V1 = 1,
V2,
V3,
V4,
V5,
V6,
V7,
V8,
V9,
V10,
V11,
V12,
V13,
V14,
V15,
// remember to update from_vote!
V16,
}
impl HardFork {
/// Returns the hard-fork for a blocks `major_version` field.
///
/// https://cuprate.github.io/monero-docs/consensus_rules/hardforks.html#blocks-version-and-vote
pub fn from_version(version: &u8) -> Result<HardFork, HardForkError> {
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(HardForkError::HardForkUnknown),
})
}
/// Returns the hard-fork for a blocks `minor_version` (vote) field.
///
/// https://cuprate.github.io/monero-docs/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<HardFork> {
HardFork::from_version(&(*self as u8 + 1)).ok()
}
/// Returns the target block time for this hardfork.
pub fn block_time(&self) -> Duration {
match self {
HardFork::V1 => BLOCK_TIME_V1,
_ => BLOCK_TIME_V2,
}
}
/// Checks a blocks version and vote, assuming that `self` is the current hard-fork.
///
/// https://cuprate.github.io/monero-book/consensus_rules/blocks.html#version-and-vote
pub fn check_block_version_vote(
current_hf: &Self,
version: &HardFork,
vote: &HardFork,
) -> Result<(), HardForkError> {
if current_hf != version {
Err(HardForkError::VersionIncorrect)?;
}
if current_hf < vote {
Err(HardForkError::VoteTooLow)?;
}
Ok(())
}
}
/// A struct holding the current voting state of the blockchain.
#[derive(Debug, Clone)]
pub struct HFVotes {
votes: [u64; NUMB_OF_HARD_FORKS],
vote_list: VecDeque<HardFork>,
window_size: usize,
}
impl Display for HFVotes {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HFVotes")
.field("total", &self.total_votes())
.field("V1", &self.votes_for_hf(&HardFork::V1))
.field("V2", &self.votes_for_hf(&HardFork::V2))
.field("V3", &self.votes_for_hf(&HardFork::V3))
.field("V4", &self.votes_for_hf(&HardFork::V4))
.field("V5", &self.votes_for_hf(&HardFork::V5))
.field("V6", &self.votes_for_hf(&HardFork::V6))
.field("V7", &self.votes_for_hf(&HardFork::V7))
.field("V8", &self.votes_for_hf(&HardFork::V8))
.field("V9", &self.votes_for_hf(&HardFork::V9))
.field("V10", &self.votes_for_hf(&HardFork::V10))
.field("V11", &self.votes_for_hf(&HardFork::V11))
.field("V12", &self.votes_for_hf(&HardFork::V12))
.field("V13", &self.votes_for_hf(&HardFork::V13))
.field("V14", &self.votes_for_hf(&HardFork::V14))
.field("V15", &self.votes_for_hf(&HardFork::V15))
.field("V16", &self.votes_for_hf(&HardFork::V16))
.finish()
}
}
impl HFVotes {
pub fn new(window_size: usize) -> HFVotes {
HFVotes {
votes: [0; NUMB_OF_HARD_FORKS],
vote_list: VecDeque::with_capacity(window_size),
window_size,
}
}
/// Add a vote for a hard-fork, this function removes votes outside of the window.
pub fn add_vote_for_hf(&mut self, hf: &HardFork) {
self.vote_list.push_back(*hf);
self.votes[*hf as usize - 1] += 1;
if self.vote_list.len() > self.window_size {
let hf = self.vote_list.pop_front().unwrap();
self.votes[hf as usize - 1] -= 1;
}
}
/// Returns the total votes for a hard-fork.
///
/// https://cuprate.github.io/monero-docs/consensus_rules/hardforks.html#accepting-a-fork
pub fn 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()
}
/// Checks if a future hard fork should be activated, returning the next hard-fork that should be
/// activated.
///
/// https://cuprate.github.io/monero-docs/consensus_rules/hardforks.html#accepting-a-fork
pub fn check_next_hard_fork(
&self,
current_hf: &HardFork,
current_height: u64,
window: u64,
hfs_info: &HFsInfo,
) -> Option<HardFork> {
let mut approved_next_hf = None;
while let Some(next_hf) = current_hf.next_fork() {
let hf_info = hfs_info.info_for_hf(&next_hf);
if current_height >= hf_info.height
&& self.votes_for_hf(&next_hf) >= votes_needed(hf_info.threshold, window)
{
approved_next_hf = Some(next_hf);
} else {
// if we don't have enough votes for this fork any future fork won't have enough votes
// as votes are cumulative.
// TODO: If a future fork has a lower threshold that could not be true, but as all current forks
// have threshold 0 it is ok for now.
return approved_next_hf;
}
}
approved_next_hf
}
}
/// Returns the votes needed for a hard-fork.
///
/// https://cuprate.github.io/monero-book/consensus_rules/hardforks.html#accepting-a-fork
pub fn votes_needed(threshold: u64, window: u64) -> u64 {
(threshold * window).div_ceil(100)
}

View file

@ -0,0 +1,53 @@
mod decomposed_amount;
mod hard_forks;
mod miner_tx;
mod signatures;
mod transactions;
pub use decomposed_amount::is_decomposed_amount;
pub use hard_forks::{HFVotes, HFsInfo, HardFork};
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
pub enum TxVersion {
RingSignatures,
RingCT,
}
impl TxVersion {
pub fn from_raw(version: u64) -> Option<TxVersion> {
Some(match version {
1 => TxVersion::RingSignatures,
2 => TxVersion::RingCT,
_ => return None,
})
}
}
/// Checks that a point is canonical.
///
/// https://github.com/dalek-cryptography/curve25519-dalek/issues/380
fn check_point(point: &curve25519_dalek::edwards::CompressedEdwardsY) -> bool {
let bytes = point.as_bytes();
point
.decompress()
// Ban points which are either unreduced or -0
.filter(|point| point.compress().as_bytes() == bytes)
.is_some()
}
#[cfg(feature = "rayon")]
fn try_par_iter<T>(t: T) -> T::Iter
where
T: rayon::iter::IntoParallelIterator,
{
t.into_par_iter()
}
#[cfg(not(feature = "rayon"))]
fn try_par_iter<T>(t: T) -> impl std::iter::Iterator<Item = T::Item>
where
T: std::iter::IntoIterator,
{
t.into_iter()
}

View file

@ -0,0 +1,189 @@
use monero_serai::{
ringct::RctType,
transaction::{Input, Output, Timelock, Transaction},
};
use crate::{is_decomposed_amount, transactions::check_output_types, HardFork, TxVersion};
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum MinerTxError {
#[error("The miners transaction version is invalid.")]
VersionInvalid,
#[error("The miner transaction does not have exactly one input.")]
IncorrectNumbOfInputs,
#[error("The miner transactions input has the wrong block height.")]
InputsHeightIncorrect,
#[error("The input is not of type `gen`.")]
InputNotOfTypeGen,
#[error("The transaction has an incorrect lock time.")]
InvalidLockTime,
#[error("The transaction has an output which is not decomposed.")]
OutputNotDecomposed,
#[error("The transaction outputs overflow when summed.")]
OutputsOverflow,
#[error("The miner transaction outputs the wrong amount.")]
OutputAmountIncorrect,
#[error("The miner transactions RCT type is not NULL.")]
RCTTypeNotNULL,
#[error("The miner transactions has an invalid output type.")]
InvalidOutputType,
}
/// A constant called "money supply", not actually a cap, it is used during
/// block reward calculations.
const MONEY_SUPPLY: u64 = u64::MAX;
/// The minimum block reward per minute, "tail-emission"
const MINIMUM_REWARD_PER_MIN: u64 = 3 * 10_u64.pow(11);
/// The value which `lock_time` should be for a coinbase output.
const MINER_TX_TIME_LOCKED_BLOCKS: u64 = 60;
/// Calculates the base block reward without taking away the penalty for expanding
/// the block.
fn calculate_base_reward(already_generated_coins: u64, hf: &HardFork) -> u64 {
let target_mins = hf.block_time().as_secs() / 60;
let emission_speed_factor = 20 - (target_mins - 1);
((MONEY_SUPPLY - already_generated_coins) >> emission_speed_factor)
.max(MINIMUM_REWARD_PER_MIN * target_mins)
}
/// Calculates the miner reward for this block.
pub fn calculate_block_reward(
block_weight: usize,
median_bw: usize,
already_generated_coins: u64,
hf: &HardFork,
) -> u64 {
let base_reward = calculate_base_reward(already_generated_coins, hf);
if block_weight <= median_bw {
return base_reward;
}
let multiplicand: u128 = ((2 * median_bw - block_weight) * block_weight)
.try_into()
.unwrap();
let effective_median_bw: u128 = median_bw.try_into().unwrap();
(((base_reward as u128 * multiplicand) / effective_median_bw) / effective_median_bw)
.try_into()
.unwrap()
}
/// Checks the miner transactions version.
///
/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#version
fn check_miner_tx_version(tx_version: &TxVersion, hf: &HardFork) -> Result<(), MinerTxError> {
// The TxVersion enum checks if the version is not 1 or 2
if hf >= &HardFork::V12 && tx_version != &TxVersion::RingCT {
Err(MinerTxError::VersionInvalid)
} else {
Ok(())
}
}
/// Checks the miner transactions inputs.
///
/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#input
/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#height
fn check_inputs(inputs: &[Input], chain_height: u64) -> Result<(), MinerTxError> {
if inputs.len() != 1 {
return Err(MinerTxError::IncorrectNumbOfInputs);
}
match &inputs[0] {
Input::Gen(height) => {
if height != &chain_height {
Err(MinerTxError::InputsHeightIncorrect)
} else {
Ok(())
}
}
_ => Err(MinerTxError::InputNotOfTypeGen),
}
}
/// Checks the miner transaction has a correct time lock.
///
/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#unlock-time
fn check_time_lock(time_lock: &Timelock, chain_height: u64) -> Result<(), MinerTxError> {
match time_lock {
Timelock::Block(till_height) => {
// Lock times above this amount are timestamps not blocks.
// This is just for safety though and shouldn't actually be hit.
if till_height >= &500_000_000 {
Err(MinerTxError::InvalidLockTime)?;
}
if u64::try_from(*till_height).unwrap() != chain_height + MINER_TX_TIME_LOCKED_BLOCKS {
Err(MinerTxError::InvalidLockTime)
} else {
Ok(())
}
}
_ => Err(MinerTxError::InvalidLockTime),
}
}
/// Sums the outputs checking for overflow.
///
/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#output-amounts
fn sum_outputs(outputs: &[Output], hf: &HardFork) -> Result<u64, MinerTxError> {
let mut sum: u64 = 0;
for out in outputs {
let amt = out.amount.unwrap_or(0);
if hf == &HardFork::V3 && !is_decomposed_amount(&amt) {
return Err(MinerTxError::OutputNotDecomposed);
}
sum = sum.checked_add(amt).ok_or(MinerTxError::OutputsOverflow)?;
}
Ok(sum)
}
/// Checks the total outputs amount is correct returning the amount of coins collected by the miner.
///
/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#total-outputs
fn check_total_output_amt(
total_output: u64,
reward: u64,
fees: u64,
hf: &HardFork,
) -> Result<u64, MinerTxError> {
if hf == &HardFork::V1 || hf >= &HardFork::V12 {
if total_output != reward + fees {
return Err(MinerTxError::OutputAmountIncorrect);
}
Ok(reward)
} else {
if total_output - fees > reward || total_output > reward + fees {
return Err(MinerTxError::OutputAmountIncorrect);
}
Ok(total_output - fees)
}
}
pub fn check_miner_tx(
tx: &Transaction,
total_fees: u64,
chain_height: u64,
block_weight: usize,
median_bw: usize,
already_generated_coins: u64,
hf: &HardFork,
) -> Result<u64, MinerTxError> {
let tx_version = TxVersion::from_raw(tx.prefix.version).ok_or(MinerTxError::VersionInvalid)?;
check_miner_tx_version(&tx_version, hf)?;
if hf >= &HardFork::V12 && tx.rct_signatures.rct_type() != RctType::Null {
return Err(MinerTxError::RCTTypeNotNULL);
}
check_time_lock(&tx.prefix.timelock, chain_height)?;
check_inputs(&tx.prefix.inputs, chain_height)?;
check_output_types(&tx.prefix.outputs, hf).map_err(|_| MinerTxError::InvalidOutputType)?;
let reward = calculate_block_reward(block_weight, median_bw, already_generated_coins, hf);
let total_outs = sum_outputs(&tx.prefix.outputs, hf)?;
check_total_output_amt(total_outs, reward, total_fees, hf)
}

View file

@ -0,0 +1,33 @@
use curve25519_dalek::EdwardsPoint;
use monero_serai::transaction::Transaction;
mod ring_signatures;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum SignatureError {
#[error("Number of signatures is different to the amount required.")]
MismatchSignatureSize,
#[error("The signature is incorrect.")]
IncorrectSignature,
}
/// Represents the ring members of all the inputs.
#[derive(Debug)]
pub enum Rings {
/// Legacy, pre-ringCT, rings.
Legacy(Vec<Vec<EdwardsPoint>>),
// RingCT rings, (outkey, amount commitment).
RingCT(Vec<Vec<[EdwardsPoint; 2]>>),
}
pub fn verify_contextual_signatures(tx: &Transaction, rings: &Rings) -> Result<(), SignatureError> {
match rings {
Rings::Legacy(_) => ring_signatures::verify_inputs_signatures(
&tx.prefix.inputs,
&tx.signatures,
rings,
&tx.signature_hash(),
),
_ => panic!("TODO: RCT"),
}
}

View file

@ -0,0 +1,51 @@
//! Version 1 ring signature verification.
//!
//! Some checks have to be done at deserialization or with data we don't have so we can't do them here, those checks are:
//! https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#signatures-must-be-canonical
//! this happens at deserialization in monero-serai.
//! https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#amount-of-signatures-in-a-ring
//! and this happens during ring signature verification in monero-serai.
//!
use monero_serai::{ring_signatures::RingSignature, transaction::Input};
#[cfg(feature = "rayon")]
use rayon::prelude::*;
use super::{Rings, SignatureError};
use crate::par_iter;
/// Verifies the ring signature.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#the-ring-signature-must-be-valid
/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#amount-of-ring-signatures
pub fn verify_inputs_signatures(
inputs: &[Input],
signatures: &[RingSignature],
rings: &Rings,
tx_sig_hash: &[u8; 32],
) -> Result<(), SignatureError> {
match rings {
Rings::Legacy(rings) => {
// rings.len() != inputs.len() can't happen but check any way.
if signatures.len() != inputs.len() || rings.len() != inputs.len() {
return Err(SignatureError::MismatchSignatureSize);
}
par_iter(inputs)
.zip(rings)
.zip(signatures)
.try_for_each(|((input, ring), sig)| {
let Input::ToKey { key_image, .. } = input else {
panic!("How did we build a ring with no decoys?");
};
if !sig.verify(tx_sig_hash, ring, key_image) {
return Err(SignatureError::IncorrectSignature);
}
Ok(())
})?;
}
_ => panic!("tried to verify v1 tx with a non v1 ring"),
}
Ok(())
}

View file

View file

@ -0,0 +1,456 @@
use std::{cmp::Ordering, collections::HashSet, sync::Arc};
use monero_serai::transaction::{Input, Output, Timelock};
use crate::{check_point, is_decomposed_amount, HardFork, TxVersion};
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum TransactionError {
//-------------------------------------------------------- OUTPUTS
#[error("Output is not a valid point.")]
OutputNotValidPoint,
#[error("The transaction has an invalid output type.")]
OutputTypeInvalid,
#[error("The transaction is v1 with a 0 amount output.")]
ZeroOutputForV1,
#[error("The transaction has an output which is not decomposed.")]
AmountNotDecomposed,
#[error("The transactions outputs overflow.")]
OutputsOverflow,
//-------------------------------------------------------- INPUTS
#[error("One or more inputs don't have the expected number of decoys.")]
InputDoesNotHaveExpectedNumbDecoys,
#[error("The transaction has more than one mixable input with unmixable inputs.")]
MoreThanOneMixableInputWithUnmixable,
#[error("The key-image is not in the prime sub-group.")]
KeyImageIsNotInPrimeSubGroup,
#[error("Key-image is already spent.")]
KeyImageSpent,
#[error("The input is not the expected type.")]
IncorrectInputType,
#[error("The transaction has a duplicate ring member.")]
DuplicateRingMember,
#[error("The transaction inputs are not ordered.")]
InputsAreNotOrdered,
#[error("The transaction spends a decoy which is too young.")]
OneOrMoreDecoysLocked,
#[error("The transaction inputs overflow.")]
InputsOverflow,
#[error("The transaction has no inputs.")]
NoInputs,
}
//----------------------------------------------------------------------------------------------------------- OUTPUTS
/// Checks the output keys are canonical points.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#output-keys-canonical
fn check_output_keys(outputs: &[Output]) -> Result<(), TransactionError> {
for out in outputs {
if !check_point(&out.key) {
return Err(TransactionError::OutputNotValidPoint);
}
}
Ok(())
}
/// Checks the output types are allowed.
///
/// This is also used during miner-tx verification.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#output-type
/// https://cuprate.github.io/monero-book/consensus_rules/blocks/miner_tx.html#output-type
pub(crate) fn check_output_types(
outputs: &[Output],
hf: &HardFork,
) -> Result<(), TransactionError> {
if hf == &HardFork::V15 {
for outs in outputs.windows(2) {
if outs[0].view_tag.is_some() != outs[0].view_tag.is_some() {
return Err(TransactionError::OutputTypeInvalid);
}
}
return Ok(());
}
for out in outputs {
if hf <= &HardFork::V14 && out.view_tag.is_some()
|| hf >= &HardFork::V16 && out.view_tag.is_none()
{
return Err(TransactionError::OutputTypeInvalid);
}
}
Ok(())
}
/// Checks the outputs amount for version 1 txs.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#output-amount
fn check_output_amount_v1(amount: u64, hf: &HardFork) -> Result<(), TransactionError> {
if amount == 0 {
return Err(TransactionError::ZeroOutputForV1);
}
if hf >= &HardFork::V2 && !is_decomposed_amount(&amount) {
return Err(TransactionError::AmountNotDecomposed);
}
Ok(())
}
/// Sums the outputs, checking for overflow and other consensus rules.
///
/// Should only be used on v1 transactions.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#inputs-and-outputs-must-not-overflow
/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#output-amount
fn sum_outputs_v1(outputs: &[Output], hf: &HardFork) -> Result<u64, TransactionError> {
let mut sum: u64 = 0;
for out in outputs {
let raw_amount = out.amount.unwrap_or(0);
check_output_amount_v1(raw_amount, hf)?;
sum = sum
.checked_add(raw_amount)
.ok_or(TransactionError::OutputsOverflow)?;
}
Ok(sum)
}
/// Checks the outputs against all output consensus rules, returning the sum of the output amounts.
pub fn check_outputs(
outputs: &[Output],
hf: &HardFork,
tx_version: &TxVersion,
) -> Result<u64, TransactionError> {
check_output_types(outputs, hf)?;
check_output_keys(outputs)?;
match tx_version {
TxVersion::RingSignatures => sum_outputs_v1(outputs, hf),
_ => todo!("RingCT"),
}
}
//----------------------------------------------------------------------------------------------------------- INPUTS
/// A struct holding information about the inputs and their decoys. This data can vary by block so
/// this data needs to be retrieved after every change in the blockchain.
///
/// This data *does not* need to be refreshed if one of these are true:
/// - The input amounts are *ALL* 0 (RCT)
/// - The top block hash is the same as when this data was retrieved (the blockchain state is unchanged).
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions/decoys.html
#[derive(Debug)]
pub struct DecoyInfo {
/// The number of inputs that have enough outputs on the chain to mix with.
pub mixable: usize,
/// The number of inputs that don't have enough outputs on the chain to mix with.
pub not_mixable: usize,
/// The minimum amount of decoys used in the transaction.
pub min_decoys: usize,
/// The maximum amount of decoys used in the transaction.
pub max_decoys: usize,
}
/// Returns the default minimum amount of decoys for a hard-fork.
/// **There are exceptions to this always being the minimum decoys**
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions/decoys.html#minimum-amount-of-decoys
fn minimum_decoys(hf: &HardFork) -> usize {
use HardFork as HF;
match hf {
HF::V1 => panic!("hard-fork 1 does not use these rules!"),
HF::V2 | HF::V3 | HF::V4 | HF::V5 => 2,
HF::V6 => 4,
HF::V7 => 6,
HF::V8 | HF::V9 | HF::V10 | HF::V11 | HF::V12 | HF::V13 | HF::V14 => 10,
HF::V15 | HF::V16 => 15,
}
}
/// Checks the decoys are allowed.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#minimum-decoys
/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#equal-number-of-decoys
fn check_decoy_info(decoy_info: &DecoyInfo, hf: &HardFork) -> Result<(), TransactionError> {
if hf == &HardFork::V15 {
// Hard-fork 15 allows both v14 and v16 rules
return check_decoy_info(decoy_info, &HardFork::V14)
.or_else(|_| check_decoy_info(decoy_info, &HardFork::V16));
}
let current_minimum_decoys = minimum_decoys(hf);
if decoy_info.min_decoys < current_minimum_decoys {
// Only allow rings without enough decoys if there aren't enough decoys to mix with.
if decoy_info.not_mixable == 0 {
return Err(TransactionError::InputDoesNotHaveExpectedNumbDecoys);
}
// Only allow upto 1 mixable input with unmixable inputs.
if decoy_info.mixable > 1 {
return Err(TransactionError::MoreThanOneMixableInputWithUnmixable);
}
} else if hf >= &HardFork::V8 && decoy_info.min_decoys != current_minimum_decoys {
// From V8 enforce the minimum used number of rings is the default minimum.
return Err(TransactionError::InputDoesNotHaveExpectedNumbDecoys);
}
// From v12 all inputs must have the same number of decoys.
if hf >= &HardFork::V12 && decoy_info.min_decoys != decoy_info.max_decoys {
return Err(TransactionError::InputDoesNotHaveExpectedNumbDecoys);
}
Ok(())
}
/// Checks the inputs key images for torsion and for duplicates in the transaction.
///
/// The `spent_kis` parameter is not meant to be a complete list of key images, just a list of related transactions
/// key images, for example transactions in a block. The chain will be checked for duplicates later.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#unique-key-image
/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#torsion-free-key-image
pub(crate) fn check_key_images(
input: &Input,
spent_kis: &mut HashSet<[u8; 32]>,
) -> Result<(), TransactionError> {
match input {
Input::ToKey { key_image, .. } => {
// this happens in monero-serai but we may as well duplicate the check.
if !key_image.is_torsion_free() {
return Err(TransactionError::KeyImageIsNotInPrimeSubGroup);
}
if !spent_kis.insert(key_image.compress().to_bytes()) {
return Err(TransactionError::KeyImageSpent);
}
}
_ => Err(TransactionError::IncorrectInputType)?,
}
Ok(())
}
/// Checks that the input is of type [`Input::ToKey`] aka txin_to_key.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#input-type
fn check_input_type(input: &Input) -> Result<(), TransactionError> {
match input {
Input::ToKey { .. } => Ok(()),
_ => Err(TransactionError::IncorrectInputType)?,
}
}
/// Checks that the input has decoys.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#inputs-must-have-decoys
fn check_input_has_decoys(input: &Input) -> Result<(), TransactionError> {
match input {
Input::ToKey { key_offsets, .. } => {
if key_offsets.is_empty() {
Err(TransactionError::InputDoesNotHaveExpectedNumbDecoys)
} else {
Ok(())
}
}
_ => Err(TransactionError::IncorrectInputType)?,
}
}
/// Checks that the ring members for the input are unique after hard-fork 6.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#unique-ring-members
fn check_ring_members_unique(input: &Input, hf: &HardFork) -> Result<(), TransactionError> {
if hf >= &HardFork::V6 {
match input {
Input::ToKey { key_offsets, .. } => key_offsets.iter().skip(1).try_for_each(|offset| {
if *offset == 0 {
Err(TransactionError::DuplicateRingMember)
} else {
Ok(())
}
}),
_ => Err(TransactionError::IncorrectInputType)?,
}
} else {
Ok(())
}
}
/// Checks that from hf 7 the inputs are sorted by key image.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#sorted-inputs
fn check_inputs_sorted(inputs: &[Input], hf: &HardFork) -> Result<(), TransactionError> {
let get_ki = |inp: &Input| match inp {
Input::ToKey { key_image, .. } => Ok(key_image.compress().to_bytes()),
_ => Err(TransactionError::IncorrectInputType),
};
if hf >= &HardFork::V7 {
for inps in inputs.windows(2) {
match get_ki(&inps[0])?.cmp(&get_ki(&inps[1])?) {
Ordering::Less => (),
_ => return Err(TransactionError::InputsAreNotOrdered),
}
}
Ok(())
} else {
Ok(())
}
}
/// Checks the youngest output is at least 10 blocks old.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions.html#10-block-lock
fn check_10_block_lock(
youngest_used_out_height: u64,
current_chain_height: u64,
hf: &HardFork,
) -> Result<(), TransactionError> {
if hf >= &HardFork::V12 {
if youngest_used_out_height + 10 > current_chain_height {
Err(TransactionError::OneOrMoreDecoysLocked)
} else {
Ok(())
}
} else {
Ok(())
}
}
/// Sums the inputs checking for overflow.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions/pre_rct.html#inputs-and-outputs-must-not-overflow
fn sum_inputs_v1(inputs: &[Input]) -> Result<u64, TransactionError> {
let mut sum: u64 = 0;
for inp in inputs {
match inp {
Input::ToKey { amount, .. } => {
sum = sum
.checked_add(amount.unwrap_or(0))
.ok_or(TransactionError::InputsOverflow)?;
}
_ => Err(TransactionError::IncorrectInputType)?,
}
}
Ok(sum)
}
/// Checks all input consensus rules.
///
/// TODO: list rules.
///
pub fn check_inputs(
inputs: &[Input],
youngest_used_out_height: u64,
current_chain_height: u64,
decoys_info: Option<&DecoyInfo>,
hf: &HardFork,
tx_version: &TxVersion,
spent_kis: Arc<std::sync::Mutex<HashSet<[u8; 32]>>>,
) -> Result<u64, TransactionError> {
if inputs.is_empty() {
return Err(TransactionError::NoInputs);
}
check_10_block_lock(youngest_used_out_height, current_chain_height, hf)?;
if let Some(decoys_info) = decoys_info {
check_decoy_info(decoys_info, hf)?;
} else {
assert_eq!(hf, &HardFork::V1);
}
for input in inputs {
check_input_type(input)?;
check_input_has_decoys(input)?;
check_ring_members_unique(input, hf)?;
let mut spent_kis_lock = spent_kis.lock().unwrap();
check_key_images(input, &mut spent_kis_lock)?;
// Adding this here for clarity so we don't add more work here while the mutex guard is still
// in scope.
drop(spent_kis_lock);
}
check_inputs_sorted(inputs, hf)?;
match tx_version {
TxVersion::RingSignatures => sum_inputs_v1(inputs),
_ => panic!("TODO: RCT"),
}
}
//----------------------------------------------------------------------------------------------------------- TIME LOCKS
/// Checks all the time locks are unlocked.
///
/// `current_time_lock_timestamp` must be: https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#getting-the-current-time
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#unlock-time
pub fn check_all_time_locks(
time_locks: &[Timelock],
current_chain_height: u64,
current_time_lock_timestamp: u64,
hf: &HardFork,
) -> Result<(), TransactionError> {
time_locks.iter().try_for_each(|time_lock| {
if !output_unlocked(
time_lock,
current_chain_height,
current_time_lock_timestamp,
hf,
) {
Err(TransactionError::OneOrMoreDecoysLocked)
} else {
Ok(())
}
})
}
/// Checks if an outputs unlock time has passed.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#unlock-time
fn output_unlocked(
time_lock: &Timelock,
current_chain_height: u64,
current_time_lock_timestamp: u64,
hf: &HardFork,
) -> bool {
match *time_lock {
Timelock::None => true,
Timelock::Block(unlock_height) => {
check_block_time_lock(unlock_height.try_into().unwrap(), current_chain_height)
}
Timelock::Time(unlock_time) => {
check_timestamp_time_lock(unlock_time, current_time_lock_timestamp, hf)
}
}
}
/// Returns if a locked output, which uses a block height, can be spend.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#block-height
fn check_block_time_lock(unlock_height: u64, current_chain_height: u64) -> bool {
// current_chain_height = 1 + top height
unlock_height <= current_chain_height
}
/// ///
/// Returns if a locked output, which uses a block height, can be spend.
///
/// https://cuprate.github.io/monero-book/consensus_rules/transactions/unlock_time.html#timestamp
fn check_timestamp_time_lock(
unlock_timestamp: u64,
current_time_lock_timestamp: u64,
hf: &HardFork,
) -> bool {
current_time_lock_timestamp + hf.block_time().as_secs() >= unlock_timestamp
}