acropolis 0.2.2

UNDER CONSTRUCTION: A safe local-first Rust Cardano node scaffold.
Documentation
use crate::chain::ChainTip;

pub const TIE_MARK_SIZE: usize = 64;
const RESTRICTED_TIE_MAX_SLOT_DISTANCE: u64 = 5;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TipOrdering {
    Equal,
    LeftBetter,
    RightBetter,
    Unknown,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TieBreakMode {
    Unknown,
    Open,
    Narrow,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TieBreakConfig {
    pub mode: TieBreakMode,
    pub max_slot_distance: u64,
}

impl TieBreakConfig {
    pub fn open() -> Self {
        Self {
            mode: TieBreakMode::Open,
            max_slot_distance: 0,
        }
    }

    pub fn restricted() -> Self {
        Self {
            mode: TieBreakMode::Narrow,
            max_slot_distance: RESTRICTED_TIE_MAX_SLOT_DISTANCE,
        }
    }

    pub fn unknown() -> Self {
        Self {
            mode: TieBreakMode::Unknown,
            max_slot_distance: 0,
        }
    }

    pub fn known(self) -> bool {
        matches!(self.mode, TieBreakMode::Open | TieBreakMode::Narrow)
    }
}

impl Default for TieBreakConfig {
    fn default() -> Self {
        Self::unknown()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TipSelectionView {
    pub slot: u64,
    pub issuer: Vec<u8>,
    pub issue_no: u64,
    pub tie_mark: Vec<u8>,
    pub tie_break: TieBreakConfig,
    has_issuer_and_issue: bool,
}

impl TipSelectionView {
    pub fn from_tip(tip: ChainTip, tie_mark: &[u8], tie_break: TieBreakConfig) -> Self {
        Self {
            slot: tip.point.slot,
            issuer: Vec::new(),
            issue_no: 0,
            tie_mark: tie_mark.to_vec(),
            tie_break,
            has_issuer_and_issue: false,
        }
    }

    pub fn full(
        tip: ChainTip,
        issuer: &[u8],
        issue_no: u64,
        tie_mark: &[u8],
        tie_break: TieBreakConfig,
    ) -> Self {
        Self {
            slot: tip.point.slot,
            issuer: issuer.to_vec(),
            issue_no,
            tie_mark: tie_mark.to_vec(),
            tie_break,
            has_issuer_and_issue: true,
        }
    }

    fn has_issuer_issue_no(&self) -> bool {
        self.has_issuer_and_issue && !self.issuer.is_empty()
    }
}

pub fn compare_chain_tips(
    left_tip: ChainTip,
    right_tip: ChainTip,
    left_view: &TipSelectionView,
    right_view: &TipSelectionView,
) -> TipOrdering {
    if left_tip.bundle_number > right_tip.bundle_number {
        return TipOrdering::LeftBetter;
    }
    if right_tip.bundle_number > left_tip.bundle_number {
        return TipOrdering::RightBetter;
    }
    if left_tip.point == right_tip.point {
        return TipOrdering::Equal;
    }
    if prefer_candidate_tip(right_view, left_view) {
        return TipOrdering::LeftBetter;
    }
    if prefer_candidate_tip(left_view, right_view) {
        return TipOrdering::RightBetter;
    }
    TipOrdering::Equal
}

pub fn prefer_candidate_tip(ours: &TipSelectionView, candidate: &TipSelectionView) -> bool {
    if issue_no_armed(ours, candidate) {
        if candidate.issue_no > ours.issue_no {
            return true;
        }
        if candidate.issue_no < ours.issue_no {
            return false;
        }
    }
    if !mark_armed(ours, candidate) {
        return false;
    }
    compare_tie_marks(&candidate.tie_mark, &ours.tie_mark) == TipOrdering::LeftBetter
}

pub fn compare_tie_marks(left: &[u8], right: &[u8]) -> TipOrdering {
    if left.len() != TIE_MARK_SIZE || right.len() != TIE_MARK_SIZE {
        return TipOrdering::Equal;
    }
    match left.cmp(right) {
        std::cmp::Ordering::Less => TipOrdering::LeftBetter,
        std::cmp::Ordering::Greater => TipOrdering::RightBetter,
        std::cmp::Ordering::Equal => TipOrdering::Equal,
    }
}

fn issue_no_armed(ours: &TipSelectionView, candidate: &TipSelectionView) -> bool {
    ours.slot == candidate.slot
        && ours.has_issuer_issue_no()
        && candidate.has_issuer_issue_no()
        && ours.issuer == candidate.issuer
}

fn mark_armed(ours: &TipSelectionView, candidate: &TipSelectionView) -> bool {
    let Some(tie_break) = tie_break_config(ours, candidate) else {
        return false;
    };
    match tie_break.mode {
        TieBreakMode::Unknown => false,
        TieBreakMode::Open => true,
        TieBreakMode::Narrow => ours.slot.abs_diff(candidate.slot) <= tie_break.max_slot_distance,
    }
}

fn tie_break_config(
    ours: &TipSelectionView,
    candidate: &TipSelectionView,
) -> Option<TieBreakConfig> {
    if ours.tie_break.known() {
        Some(ours.tie_break)
    } else if candidate.tie_break.known() {
        Some(candidate.tie_break)
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::chain::{ChainPoint, ChainTip};

    fn tip(slot: u64, bundle_number: u64, hash_byte: u8) -> ChainTip {
        ChainTip::new(ChainPoint::new(slot, [hash_byte; 32]), bundle_number)
    }

    #[test]
    fn higher_bundle_number_wins() {
        let left = tip(10, 10, 1);
        let right = tip(11, 11, 2);
        let left_view =
            TipSelectionView::from_tip(left, &[0; TIE_MARK_SIZE], TieBreakConfig::open());
        let right_view =
            TipSelectionView::from_tip(right, &[0; TIE_MARK_SIZE], TieBreakConfig::open());
        assert_eq!(
            compare_chain_tips(left, right, &left_view, &right_view),
            TipOrdering::RightBetter
        );
    }

    #[test]
    fn invalid_mark_lengths_do_not_win() {
        assert_eq!(
            compare_tie_marks(&[0; 63], &[1; TIE_MARK_SIZE]),
            TipOrdering::Equal
        );
    }

    #[test]
    fn lower_mark_wins_when_armed() {
        assert_eq!(
            compare_tie_marks(&[1; TIE_MARK_SIZE], &[2; TIE_MARK_SIZE]),
            TipOrdering::LeftBetter
        );
    }

    #[test]
    fn narrow_tie_ignores_distant_slots() {
        let ours_tip = tip(10, 9, 1);
        let candidate_tip = tip(20, 9, 2);
        let ours =
            TipSelectionView::from_tip(ours_tip, &[9; TIE_MARK_SIZE], TieBreakConfig::restricted());
        let candidate = TipSelectionView::from_tip(
            candidate_tip,
            &[1; TIE_MARK_SIZE],
            TieBreakConfig::restricted(),
        );
        assert!(!prefer_candidate_tip(&ours, &candidate));
    }

    #[test]
    fn higher_issue_number_wins_for_same_issuer_and_slot() {
        let ours_tip = tip(10, 9, 1);
        let candidate_tip = tip(10, 9, 2);
        let ours = TipSelectionView::full(
            ours_tip,
            b"issuer",
            1,
            &[9; TIE_MARK_SIZE],
            TieBreakConfig::open(),
        );
        let candidate = TipSelectionView::full(
            candidate_tip,
            b"issuer",
            2,
            &[8; TIE_MARK_SIZE],
            TieBreakConfig::open(),
        );
        assert!(prefer_candidate_tip(&ours, &candidate));
    }
}