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