Skip to main content

ore_api/state/
round.rs

1use serde::{Deserialize, Serialize};
2use solana_program::keccak;
3use steel::*;
4
5use crate::state::{round_pda, OreAccount};
6
7#[repr(C)]
8#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable, Serialize, Deserialize)]
9pub struct Round {
10    /// The round number.
11    pub id: u64,
12
13    /// The amount of SOL deployed in each square.
14    /// TODO: Rename to sol.
15    pub deployed: [u64; 25],
16
17    /// The amount of mass deployed in each square.
18    pub mass: [u64; 25],
19
20    /// The number of unique miners on each square.
21    /// TODO rename to miners.
22    pub count: [u64; 25],
23
24    /// The entropy value.
25    /// TODO: Rename to entropy.
26    pub slot_hash: [u8; 32],
27
28    /// The slot after which this account may be closed.
29    /// TODO: Rename to closes_at.
30    pub expires_at: u64,
31
32    /// The amount of ORE distributed as the motherlode reward.
33    pub motherlode: u64,
34
35    /// The account to which rent should be returned to when this account is closed.
36    pub rent_payer: Pubkey,
37
38    /// The amount of ORE to distribute to miners.
39    pub rewards: [u64; 25],
40
41    /// The total SOL collected by the protocol.
42    /// TODO: Rename to protocol_fee.
43    pub total_vaulted: u64,
44
45    /// The total SOL returned to miners.
46    /// TODO: Rename to total_returned.
47    pub total_winnings: u64,
48
49    /// The total number of unique miners that played in the round.
50    /// TODO rename to unique_miners.
51    pub total_miners: u64,
52
53    /// The winner of the solo reward.
54    /// TODO: Rename to winner.
55    pub top_miner: Pubkey,
56}
57
58impl Round {
59    pub fn pda(&self) -> (Pubkey, u8) {
60        round_pda(self.id)
61    }
62
63    pub fn rng(&self) -> Option<u64> {
64        if self.slot_hash == [0; 32] || self.slot_hash == [u8::MAX; 32] {
65            return None;
66        }
67        let r1 = u64::from_le_bytes(self.slot_hash[0..8].try_into().unwrap());
68        let r2 = u64::from_le_bytes(self.slot_hash[8..16].try_into().unwrap());
69        let r3 = u64::from_le_bytes(self.slot_hash[16..24].try_into().unwrap());
70        let r4 = u64::from_le_bytes(self.slot_hash[24..32].try_into().unwrap());
71        let r = r1 ^ r2 ^ r3 ^ r4;
72        Some(r)
73    }
74
75    pub fn winning_square(&self, rng: u64) -> usize {
76        (rng % 25) as usize
77    }
78
79    pub fn top_miner_sample(&self, rng: u64, winning_square: usize) -> u64 {
80        if self.deployed[winning_square] == 0 {
81            return 0;
82        }
83        rng.reverse_bits() % self.deployed[winning_square]
84    }
85
86    pub fn calculate_total_winnings(&self, winning_square: usize) -> u64 {
87        let mut total_winnings = 0;
88        for (i, &deployed) in self.deployed.iter().enumerate() {
89            if i != winning_square {
90                total_winnings += deployed;
91            }
92        }
93        total_winnings
94    }
95
96    pub fn is_split_reward(&self, rng: u64) -> bool {
97        // One out of four rounds get split rewards.
98        let rng = rng.reverse_bits().to_le_bytes();
99        let r1 = u16::from_le_bytes(rng[0..2].try_into().unwrap());
100        let r2 = u16::from_le_bytes(rng[2..4].try_into().unwrap());
101        let r3 = u16::from_le_bytes(rng[4..6].try_into().unwrap());
102        let r4 = u16::from_le_bytes(rng[6..8].try_into().unwrap());
103        let r = r1 ^ r2 ^ r3 ^ r4;
104        r % 2 == 0
105    }
106
107    /// Determines if the reward on a given tile (winning_square) is split under the new reward distribution.
108    /// Returns true if the reward is split (bit at winning_square index is 0), false otherwise.
109    pub fn is_split_reward_v2(&self, winning_square: usize) -> bool {
110        self.distribution_mask() & (1 << winning_square) == 0
111    }
112
113    pub fn did_hit_motherlode(&self, rng: u64) -> bool {
114        rng.reverse_bits() % 500 == 0
115    }
116
117    pub fn total_deployed(&self) -> u64 {
118        self.deployed.iter().sum()
119    }
120
121    pub fn top_miner_reward(&self) -> u64 {
122        self.rewards.iter().sum()
123    }
124
125    /// Generates a mask that indicates how rewards are distributed on each tile.
126    /// The mask is a 32-bit integer where the first 25 bits represent the tiles.
127    /// The bits are set to 0 if the reward on that tile is split.
128    /// The bits are set to 1 if the reward on that tile is not split.
129    /// The mask is generated using a Fisher-Yates shuffle of the rng hash.
130    /// The shuffle is done using a Fisher-Yates shuffle for unbiased selection.
131    pub fn distribution_mask(&self) -> u32 {
132        const BITS: u32 = 10;
133        let rng = keccak::hashv(&[self.id.to_le_bytes().as_ref()]);
134
135        // Deterministically select 10 unique indices out of 25 (first 25 bits)
136        // using Fisher-Yates shuffle seeded from rng for reproducibility.
137        let mut indices: [u8; 25] = [0; 25];
138        for i in 0..25 {
139            indices[i] = i as u8;
140        }
141
142        // Use bytes from the rng hash as randomness source
143        let mut randomness = rng.0;
144        let mut random_offset = 0;
145
146        // Do a Fisher-Yates shuffle for unbiased selection
147        for i in (1..25).rev() {
148            // If we've used up all the randomness, rehash (although 32 bytes is plenty for this)
149            if random_offset + 2 > randomness.len() {
150                // rehash for more randomness (although shouldn't be needed for 25 draws)
151                randomness = keccak::hashv(&[&randomness]).0;
152                random_offset = 0;
153            }
154            let mut two_bytes = [0u8; 2];
155            two_bytes.copy_from_slice(&randomness[random_offset..random_offset + 2]);
156            let r = u16::from_le_bytes(two_bytes);
157            let j = (r as usize) % (i + 1);
158            indices.swap(i, j);
159            random_offset += 2;
160        }
161
162        // Set mask bits for the first 10 shuffled indices
163        let mut mask: u32 = 0;
164        for &idx in &indices[..BITS as usize] {
165            mask |= 1 << idx;
166        }
167
168        // Only first 25 bits are used, highest 7 bits remain 0
169        mask
170    }
171}
172
173account!(OreAccount, Round);
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    fn default_round(id: u64) -> Round {
180        Round {
181            id,
182            deployed: [0; 25],
183            mass: [0; 25],
184            count: [0; 25],
185            slot_hash: [0; 32],
186            expires_at: 0,
187            motherlode: 0,
188            rent_payer: Pubkey::default(),
189            rewards: [0; 25],
190            total_vaulted: 0,
191            total_winnings: 0,
192            total_miners: 0,
193            top_miner: Pubkey::default(),
194        }
195    }
196
197    #[test]
198    fn test_distribution_mask_has_exactly_10_bits_set() {
199        for id in 0..1000 {
200            let round = default_round(id);
201            let mask = round.distribution_mask();
202            assert_eq!(
203                mask.count_ones(),
204                10,
205                "Round {id}: expected 10 bits set, got {}",
206                mask.count_ones()
207            );
208        }
209    }
210
211    #[test]
212    fn test_distribution_mask_only_uses_first_25_bits() {
213        for id in 0..1000 {
214            let round = default_round(id);
215            let mask = round.distribution_mask();
216            assert_eq!(
217                mask & !((1u32 << 25) - 1),
218                0,
219                "Round {id}: bits above position 24 should not be set"
220            );
221        }
222    }
223
224    #[test]
225    fn test_distribution_mask_is_deterministic() {
226        for id in 0..100 {
227            let round = default_round(id);
228            let mask1 = round.distribution_mask();
229            let mask2 = round.distribution_mask();
230            assert_eq!(mask1, mask2, "Round {id}: mask should be deterministic");
231        }
232    }
233
234    #[test]
235    fn test_distribution_mask_values_are_randomized() {
236        let mut masks = std::collections::HashSet::new();
237        for id in 0..100 {
238            let round = default_round(id);
239            masks.insert(round.distribution_mask());
240        }
241        assert!(
242            masks.len() > 50,
243            "Expected diverse mask values across rounds, only got {} unique values out of 100",
244            masks.len()
245        );
246    }
247}