Skip to main content

satrush_client/
hashrate.rs

1/// Display decimals of hashrate points, mirroring the program's
2/// `HASHRATE_DECIMALS`: 1 raw unit = 0.01 point.
3pub const HASHRATE_DECIMALS: u8 = 2;
4
5/// Raw hashrate units per vault ticket, mirroring the program's
6/// `HASHRATE_PER_TICKET` (10^HASHRATE_DECIMALS): one ticket costs 1.00 point.
7pub const HASHRATE_PER_TICKET: u64 = 100;
8
9/// Loyalty channel weight (α) in the hashrate reward formula, mirroring the
10/// program's `LOYALTY_WEIGHT`.
11pub const LOYALTY_WEIGHT: u64 = 1;
12
13/// Skill channel weight (β) in the hashrate reward formula, mirroring the
14/// program's `SKILL_WEIGHT`.
15pub const SKILL_WEIGHT: u64 = 1;
16
17/// Format raw hashrate units as the UI points amount with 2 decimals
18/// ([`HASHRATE_DECIMALS`]): raw 101 → `"1.01"`. Lossless for the full u64
19/// range, unlike an f64 conversion.
20pub fn get_hashrate_ui_amount(hashrate: u64) -> String {
21    format!("{}.{:02}", hashrate / HASHRATE_PER_TICKET, hashrate % HASHRATE_PER_TICKET)
22}
23
24/// Whole vault tickets purchasable with `hashrate` raw units, flooring at
25/// [`HASHRATE_PER_TICKET`] raw units (1.00 point) per ticket.
26pub fn get_hashrate_tickets_count(hashrate: u64) -> u64 {
27    hashrate / HASHRATE_PER_TICKET
28}
29
30/// The reward one settled play would credit, split into its channels for the
31/// emitted event, as computed by [`hashrate_reward`]. `loyalty_points +
32/// skill_points == total` by construction.
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub struct HashrateReward {
35    /// α·m channel contribution (absorbs the rounding remainder).
36    pub loyalty_points: u64,
37    /// β·N/n channel contribution.
38    pub skill_points: u64,
39    /// Total points that would be added to `Miner::hashrate_amount`.
40    pub total: u64,
41}
42
43#[derive(Debug, thiserror::Error)]
44pub enum HashrateRewardError {
45    #[error("hashrate reward overflows u64")]
46    MathOverflow,
47}
48
49/// Hashrate points a play would credit, mirroring the program's
50/// `hashrate_reward` so a caller can preview them before deploying or
51/// settling.
52///
53/// - `stake` — net USD stake in the mint's native units
54///   (`PublicDeployment::deployed_usd_amount` for an existing deployment).
55/// - `streak` — the streak multiplier `m`, already capped
56///   (`PublicDeployment::streak_multiplier` for an existing deployment, or
57///   [`crate::next_streak_multiplier`] to preview an upcoming one).
58/// - `covered` / `total_tiles` — tiles selected (`n`) out of the board
59///   (`N`, [`crate::TILE_COUNT`]); `covered` is in `1..=total_tiles`.
60/// - `usd_unit` — `10^decimals` of the USD mint.
61///
62/// The returned points are raw units with 2 display decimals
63/// ([`HASHRATE_DECIMALS`]): raw 101 = 1.01 points.
64///
65/// Returns an all-zero reward when `stake == 0` or `covered == 0`.
66pub fn hashrate_reward(
67    stake: u64,
68    streak: u32,
69    covered: u32,
70    total_tiles: u32,
71    usd_unit: u64,
72) -> Result<HashrateReward, HashrateRewardError> {
73    // No stake or no coverage → no points (also guards the `covered` divisor).
74    if stake == 0 || covered == 0 {
75        return Ok(HashrateReward::default());
76    }
77
78    let covered = covered as u128;
79    let denom = covered * usd_unit as u128;
80    let base = stake as u128;
81
82    // Channel numerators share `base`; clearing the `N/n` division in the
83    // single final divide keeps rounding to one floor.
84    let skill_num = base * (SKILL_WEIGHT as u128 * total_tiles as u128);
85    let loyalty_num = base * (LOYALTY_WEIGHT as u128 * streak as u128 * covered);
86
87    let total: u64 = ((loyalty_num + skill_num) / denom)
88        .try_into()
89        .map_err(|_| HashrateRewardError::MathOverflow)?;
90    // `total >= skill` since `loyalty_num >= 0`, so `loyalty` never underflows.
91    // Loyalty absorbs the rounding remainder so the channels sum to `total`.
92    let skill_points: u64 = (skill_num / denom) as u64;
93    let loyalty_points = total - skill_points;
94
95    Ok(HashrateReward {
96        loyalty_points,
97        skill_points,
98        total,
99    })
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    const USD_UNIT: u64 = 1_000_000; // 6-decimal USDC
107    const N: u32 = 21;
108
109    #[test]
110    fn worked_example_two_dollars_streak_15_three_tiles() {
111        // s = $2, m = 15, n = 3, N = 21 → 44 points.
112        let r = hashrate_reward(2_000_000, 15, 3, N, USD_UNIT).unwrap();
113        assert_eq!(r.total, 44);
114    }
115
116    #[test]
117    fn channels_sum_to_total() {
118        let r = hashrate_reward(2_000_000, 15, 3, N, USD_UNIT).unwrap();
119        assert_eq!(r.loyalty_points + r.skill_points, r.total);
120    }
121
122    #[test]
123    fn full_coverage_has_no_skill_bonus_beyond_floor() {
124        // n = N → N/n = 1. Multiplier = α·m + β·1. With m = 1: 1 + 1 = 2.
125        // $1 stake → 2 points; skill channel contributes β·1 = 1.
126        let r = hashrate_reward(1_000_000, 1, N, N, USD_UNIT).unwrap();
127        assert_eq!(r.total, 2);
128        assert_eq!(r.skill_points, 1);
129        assert_eq!(r.loyalty_points, 1);
130    }
131
132    #[test]
133    fn single_tile_is_max_conviction() {
134        // n = 1 → N/n = N = 21. m = 1, $1 stake.
135        // Multiplier = α·1 + β·21 = 22 → 22 points.
136        let r = hashrate_reward(1_000_000, 1, 1, N, USD_UNIT).unwrap();
137        assert_eq!(r.total, 22);
138    }
139
140    #[test]
141    fn ceiling_multiplier_per_dollar() {
142        // A high streak, n = 1 → α·30 + β·21 = 51. $1 stake → 51 points.
143        let r = hashrate_reward(1_000_000, 30, 1, N, USD_UNIT).unwrap();
144        assert_eq!(r.total, 51);
145    }
146
147    #[test]
148    fn one_dollar_at_max_streak_earns_1_01_points() {
149        // m = 100, full coverage → 101 raw units = 1.01 points under the
150        // 2-decimal convention (HASHRATE_DECIMALS).
151        let r = hashrate_reward(1_000_000, 100, N, N, USD_UNIT).unwrap();
152        assert_eq!(r.total, 101);
153        assert_eq!(HASHRATE_PER_TICKET, 10u64.pow(HASHRATE_DECIMALS as u32));
154    }
155
156    #[test]
157    fn zero_stake_earns_nothing() {
158        let r = hashrate_reward(0, 30, 1, N, USD_UNIT).unwrap();
159        assert_eq!(r, HashrateReward::default());
160    }
161
162    #[test]
163    fn zero_coverage_earns_nothing_without_dividing_by_zero() {
164        let r = hashrate_reward(1_000_000, 5, 0, N, USD_UNIT).unwrap();
165        assert_eq!(r, HashrateReward::default());
166    }
167
168    #[test]
169    fn sub_unit_stake_floors_to_zero() {
170        // $0.01 stake at base multiplier floors to 0 points.
171        let r = hashrate_reward(10_000, 1, N, N, USD_UNIT).unwrap();
172        assert_eq!(r.total, 0);
173    }
174
175    #[test]
176    fn ui_amount_formats_raw_units_with_two_decimals() {
177        assert_eq!(get_hashrate_ui_amount(101), "1.01");
178        assert_eq!(get_hashrate_ui_amount(0), "0.00");
179        assert_eq!(get_hashrate_ui_amount(5), "0.05");
180        assert_eq!(get_hashrate_ui_amount(230), "2.30");
181        assert_eq!(get_hashrate_ui_amount(u64::MAX), "184467440737095516.15");
182    }
183
184    #[test]
185    fn tickets_count_floors_raw_units_to_whole_tickets() {
186        assert_eq!(get_hashrate_tickets_count(0), 0);
187        assert_eq!(get_hashrate_tickets_count(99), 0);
188        assert_eq!(get_hashrate_tickets_count(100), 1);
189        assert_eq!(get_hashrate_tickets_count(6_550), 65);
190        assert_eq!(get_hashrate_tickets_count(u64::MAX), u64::MAX / 100);
191    }
192
193    #[test]
194    fn matches_next_streak_multiplier_for_an_upcoming_deploy() {
195        // Preview an upcoming deploy: miner played round 5 last, streak 4;
196        // deploying into round 6 (consecutive) with full coverage.
197        let streak = crate::next_streak_multiplier(4, 5, 6);
198        assert_eq!(streak, 5);
199        let r = hashrate_reward(1_000_000, streak, N, N, USD_UNIT).unwrap();
200        assert_eq!(r.total, 6);
201    }
202}