diff --git a/api/src/consts.rs b/api/src/consts.rs index 87eebb4..81fa19e 100644 --- a/api/src/consts.rs +++ b/api/src/consts.rs @@ -41,6 +41,9 @@ pub const SQUARE: &[u8] = b"square"; /// The seed of the stake account PDA. pub const STAKE: &[u8] = b"stake"; +/// The seed of the round account PDA. +pub const ROUND: &[u8] = b"round"; + /// The seed of the treasury account PDA. pub const TREASURY: &[u8] = b"treasury"; diff --git a/api/src/instruction.rs b/api/src/instruction.rs index 06a6cb4..9d05a6f 100644 --- a/api/src/instruction.rs +++ b/api/src/instruction.rs @@ -6,26 +6,27 @@ pub enum OreInstruction { // Miner Automate = 0, Boost = 1, - ClaimSOL = 2, - ClaimORE = 3, - Deploy = 4, - Initialize = 5, - Log = 6, - Reset = 7, + Checkpoint = 2, + ClaimSOL = 3, + ClaimORE = 4, + Deploy = 5, + Initialize = 6, + Log = 7, + Reset = 8, // Staker - Deposit = 8, - Withdraw = 9, - ClaimYield = 10, + Deposit = 9, + Withdraw = 10, + ClaimYield = 11, // Admin - Bury = 11, - Wrap = 12, - SetAdmin = 13, - SetFeeCollector = 14, + Bury = 12, + Wrap = 13, + SetAdmin = 14, + SetFeeCollector = 15, // Seeker - ClaimSeeker = 15, + ClaimSeeker = 16, } #[repr(C)] @@ -144,8 +145,13 @@ pub struct ClaimYield { #[derive(Clone, Copy, Debug, Pod, Zeroable)] pub struct ClaimSeeker {} +#[repr(C)] +#[derive(Clone, Copy, Debug, Pod, Zeroable)] +pub struct Checkpoint {} + instruction!(OreInstruction, Automate); instruction!(OreInstruction, Boost); +instruction!(OreInstruction, Checkpoint); instruction!(OreInstruction, ClaimSOL); instruction!(OreInstruction, ClaimORE); instruction!(OreInstruction, Deploy); diff --git a/api/src/sdk.rs b/api/src/sdk.rs index 27fb9b2..478897a 100644 --- a/api/src/sdk.rs +++ b/api/src/sdk.rs @@ -160,7 +160,7 @@ pub fn deploy( let automation_address = automation_pda(authority).0; let board_address = board_pda().0; let miner_address = miner_pda(authority).0; - let square_address = square_pda().0; + // let square_address = square_pda().0; // Convert array of 25 booleans into a 32-bit mask where each bit represents whether // that square index is selected (1) or not (0) @@ -177,7 +177,7 @@ pub fn deploy( AccountMeta::new(automation_address, false), AccountMeta::new(board_address, false), AccountMeta::new(miner_address, false), - AccountMeta::new(square_address, false), + // AccountMeta::new(square_address, false), AccountMeta::new_readonly(system_program::ID, false), ]; @@ -285,7 +285,7 @@ pub fn reset(signer: Pubkey, fee_collector: Pubkey, miners: Vec) -> Inst let board_address = board_pda().0; let config_address = config_pda().0; let mint_address = MINT_ADDRESS; - let square_address = square_pda().0; + // let square_address = square_pda().0; let treasury_address = TREASURY_ADDRESS; let treasury_tokens_address = treasury_tokens_address(); let mut accounts = vec![ @@ -294,7 +294,7 @@ pub fn reset(signer: Pubkey, fee_collector: Pubkey, miners: Vec) -> Inst AccountMeta::new(config_address, false), AccountMeta::new(fee_collector, false), AccountMeta::new(mint_address, false), - AccountMeta::new(square_address, false), + // AccountMeta::new(square_address, false), AccountMeta::new(treasury_address, false), AccountMeta::new(treasury_tokens_address, false), AccountMeta::new_readonly(system_program::ID, false), diff --git a/api/src/state/board.rs b/api/src/state/board.rs index 721210d..032b661 100644 --- a/api/src/state/board.rs +++ b/api/src/state/board.rs @@ -6,7 +6,7 @@ use super::OreAccount; #[repr(C)] #[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)] -pub struct Game { +pub struct Board { /// The current round number. pub round_id: u64, @@ -35,9 +35,18 @@ pub struct Round { /// The slot at which claims for this round account end. pub expires_at: i64, + /// The amount of ORE in the motherlode. + pub motherlode: u64, + + /// The account to which rent should be returned when this account is closed. + pub rent_payer: Pubkey, + /// The top miner of the round. pub top_miner: Pubkey, + /// The amount of ORE to distribute to the top miner. + pub top_miner_reward: u64, + /// The total amount of SOL deployed in the round. pub total_deployed: u64, @@ -48,23 +57,66 @@ pub struct Round { pub total_winnings: u64, } -#[repr(C)] -#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)] -pub struct Move { - /// The authority of the move. - pub authority: Pubkey, +impl Round { + pub fn rng(&self) -> u64 { + if self.slot_hash == [0; 32] { + return 0; + } + let r1 = u64::from_le_bytes(self.slot_hash[0..8].try_into().unwrap()); + let r2 = u64::from_le_bytes(self.slot_hash[8..16].try_into().unwrap()); + let r3 = u64::from_le_bytes(self.slot_hash[16..24].try_into().unwrap()); + let r4 = u64::from_le_bytes(self.slot_hash[24..32].try_into().unwrap()); + let r = r1 ^ r2 ^ r3 ^ r4; + r + } - /// The amount of SOL deployed in each square. - pub deployed: [u64; 25], + pub fn winning_square(&self, rng: u64) -> usize { + (rng % 25) as usize + } - /// The round number. - pub round_id: u64, + pub fn top_miner_sample(&self, rng: u64, winning_square: usize) -> u64 { + if self.deployed[winning_square] == 0 { + return 0; + } + rng.reverse_bits() % self.deployed[winning_square] + } + + pub fn calculate_total_winnings(&self, winning_square: usize) -> u64 { + let mut total_winnings = 0; + for (i, &deployed) in self.deployed.iter().enumerate() { + if i != winning_square { + total_winnings += deployed; + } + } + total_winnings + } + + pub fn did_motherlode_hit(&self, rng: u64) -> bool { + rng.reverse_bits() % 625 == 0 + } } -// impl Board { -// pub fn pda(&self) -> (Pubkey, u8) { -// board_pda() -// } -// } +impl Board { + pub fn pda(&self) -> (Pubkey, u8) { + board_pda() + } +} -// account!(OreAccount, Board); +account!(OreAccount, Board); + +account!(OreAccount, Round); + +#[cfg(test)] +mod tests { + use solana_program::rent::Rent; + + use super::*; + + #[test] + fn test_rent() { + let size_of_round = 8 + std::mem::size_of::(); + let required_rent = Rent::default().minimum_balance(size_of_round); + println!("required_rent: {}", required_rent); + assert!(false); + } +} diff --git a/api/src/state/miner.rs b/api/src/state/miner.rs index f636d85..53c04c3 100644 --- a/api/src/state/miner.rs +++ b/api/src/state/miner.rs @@ -13,9 +13,14 @@ pub struct Miner { /// The miner's prospects in the current round. pub deployed: [u64; 25], - /// Unused buffer. - #[deprecated(note = "No longer used")] - pub buffer: [u8; 32], + /// The cumulative amount of SOL deployed on each square prior to this miner's move. + pub cumulative: [u64; 25], + + /// SOL witheld in reserve to pay for checkpointing. + pub checkpoint_fee: u64, + + /// The last round that this miner checkpointed. + pub checkpoint_id: u64, /// The amount of SOL this miner has had refunded and may claim. pub refund_sol: u64, diff --git a/api/src/state/mod.rs b/api/src/state/mod.rs index a885d12..364cb24 100644 --- a/api/src/state/mod.rs +++ b/api/src/state/mod.rs @@ -3,7 +3,7 @@ mod board; mod config; mod miner; mod seeker; -mod square; +// mod square; mod stake; mod treasury; @@ -12,7 +12,7 @@ pub use board::*; pub use config::*; pub use miner::*; pub use seeker::*; -pub use square::*; +// pub use square::*; pub use stake::*; pub use treasury::*; @@ -30,9 +30,10 @@ pub enum OreAccount { // Board = 105, - Square = 106, + // Square = 106, Seeker = 107, Stake = 108, + Round = 109, } pub fn automation_pda(authority: Pubkey) -> (Pubkey, u8) { @@ -55,8 +56,12 @@ pub fn seeker_pda(mint: Pubkey) -> (Pubkey, u8) { Pubkey::find_program_address(&[SEEKER, &mint.to_bytes()], &crate::ID) } -pub fn square_pda() -> (Pubkey, u8) { - Pubkey::find_program_address(&[SQUARE], &crate::ID) +// pub fn square_pda() -> (Pubkey, u8) { +// Pubkey::find_program_address(&[SQUARE], &crate::ID) +// } + +pub fn round_pda(id: u64) -> (Pubkey, u8) { + Pubkey::find_program_address(&[ROUND, &id.to_le_bytes()], &crate::ID) } pub fn stake_pda(authority: Pubkey) -> (Pubkey, u8) { diff --git a/program/src/checkpoint.rs b/program/src/checkpoint.rs new file mode 100644 index 0000000..fc1c886 --- /dev/null +++ b/program/src/checkpoint.rs @@ -0,0 +1,94 @@ +use ore_api::prelude::*; +use solana_program::rent::Rent; +use steel::*; + +// TODO Bot fees + +/// Checkpoints a miner's rewards. +pub fn process_checkpoint(accounts: &[AccountInfo<'_>], _data: &[u8]) -> ProgramResult { + // Load accounts. + let clock = Clock::get()?; + let [signer_info, board_info, miner_info, round_info, treasury_info, system_program] = accounts + else { + return Err(ProgramError::NotEnoughAccountKeys); + }; + signer_info.is_signer()?; + let board = board_info.as_account::(&ore_api::ID)?; + let miner = miner_info + .as_account_mut::(&ore_api::ID)? + .assert_mut(|m| m.checkpoint_id < m.round_id)?; // Ensure miner has not already checkpointed this round. + if round_info.data_is_empty() { + // If round account is empty, ensure the correct account was provided. + // This can happen if the miner attempted to checkpoint after the round expired and the account was closed. + // In this case, the miner forfeits any potential rewards and their checkpoint is recorded. + round_info.has_seeds(&[ROUND, &miner.round_id.to_le_bytes()], &ore_api::ID)?; + miner.checkpoint_id = miner.round_id; + return Ok(()); + } + let round = round_info + .as_account_mut::(&ore_api::ID)? + .assert_mut(|r| r.id < board.round_id)? // Ensure round has ended. + .assert_mut(|r| r.id == miner.round_id)?; // Ensure miner round ID matches the provided round. + treasury_info.as_account::(&ore_api::ID)?; + system_program.is_program(&system_program::ID)?; + + // Ensure round is not expired. + if clock.unix_timestamp >= round.expires_at { + // In this case, the miner forfeits any potential rewards and their checkpoint is recorded. + miner.checkpoint_id = round.id; + return Ok(()); + } + + // Calculate miner rewards. + let mut rewards_sol = 0; + let mut rewards_ore = 0; + let r = round.rng(); + let winning_square = round.winning_square(r) as usize; + if miner.deployed[winning_square] > 0 { + // Sanity check. + assert!( + round.deployed[winning_square] >= miner.deployed[winning_square], + "Invalid round deployed amount" + ); + + // Calculate SOL rewards. + rewards_sol = ((round.total_winnings as u128 * miner.deployed[winning_square] as u128) + / round.deployed[winning_square] as u128) as u64; + + // Calculate ORE rewards. + let top_miner_sample = round.top_miner_sample(r, winning_square); + if top_miner_sample >= miner.cumulative[winning_square] + && top_miner_sample < miner.cumulative[winning_square] + miner.deployed[winning_square] + { + rewards_ore = round.top_miner_reward; + } + + // Calculate motherlode rewards. + if round.motherlode > 0 { + rewards_ore += ((round.motherlode as u128 * miner.deployed[winning_square] as u128) + / round.deployed[winning_square] as u128) as u64; + } + } + + // Checkpoint miner. + miner.checkpoint_id = round.id; + miner.rewards_ore += rewards_ore; + miner.lifetime_rewards_ore += rewards_ore; + miner.rewards_sol += rewards_sol; + miner.lifetime_rewards_sol += rewards_sol; + + // Do SOL transfers. + if rewards_sol > 0 { + round_info.send(rewards_sol, &miner_info); + } + + // Assert round has sufficient funds for rent. + let account_size = 8 + std::mem::size_of::(); + let required_rent = Rent::get()?.minimum_balance(account_size); + assert!( + round_info.lamports() >= required_rent, + "Round does not have sufficient funds for rent" + ); + + Ok(()) +} diff --git a/program/src/deploy.rs b/program/src/deploy.rs index 0698562..a972b74 100644 --- a/program/src/deploy.rs +++ b/program/src/deploy.rs @@ -11,9 +11,8 @@ pub fn process_deploy(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResul // Load accounts. let clock = Clock::get()?; - let (required_accounts, miner_accounts) = accounts.split_at(7); - let [signer_info, authority_info, automation_info, board_info, miner_info, square_info, system_program] = - required_accounts + let [signer_info, authority_info, automation_info, board_info, miner_info, round_info, round_prev_info, system_program] = + accounts else { return Err(ProgramError::NotEnoughAccountKeys); }; @@ -22,12 +21,11 @@ pub fn process_deploy(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResul automation_info.is_writable()?; let board = board_info .as_account_mut::(&ore_api::ID)? - .assert_mut(|b| { - (clock.slot >= b.start_slot && clock.slot < b.end_slot && b.slot_hash == [0; 32]) - || (clock.slot >= b.end_slot + INTERMISSION_SLOTS && b.slot_hash != [0; 32]) - })?; + .assert_mut(|b| clock.slot >= b.start_slot && clock.slot < b.end_slot)?; + let round = round_info + .as_account_mut::(&ore_api::ID)? + .assert_mut(|r| r.id == board.round_id)?; miner_info.is_writable()?; - let square = square_info.as_account_mut::(&ore_api::ID)?; system_program.is_program(&system_program::ID)?; // Check if signer is the automation executor. @@ -58,7 +56,7 @@ pub fn process_deploy(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResul AutomationStrategy::Random => { // Random automation strategy. Generate a random mask based on number of squares user wants to deploy to. let num_squares = ((automation.mask & 0xFF) as u64).min(25); - let r = hashv(&[&automation.authority.to_bytes(), &board.id.to_le_bytes()]).0; + let r = hashv(&[&automation.authority.to_bytes(), &round.id.to_le_bytes()]).0; squares = generate_random_mask(num_squares, &r); } } @@ -74,7 +72,7 @@ pub fn process_deploy(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResul sol_log( &format!( "Round {}. Deploying {} SOL to {} squares", - board.id, + round.id, lamports_to_sol(amount), squares.iter().filter(|&&s| s).count(), ) @@ -93,10 +91,12 @@ pub fn process_deploy(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResul let miner = miner_info.as_account_mut::(&ore_api::ID)?; miner.authority = *signer_info.key; miner.deployed = [0; 25]; + miner.cumulative = [0; 25]; miner.refund_sol = 0; miner.rewards_sol = 0; miner.rewards_ore = 0; - miner.round_id = board.id; + miner.round_id = round.id; + miner.checkpoint_id = round.id; miner.lifetime_rewards_sol = 0; miner.lifetime_rewards_ore = 0; miner @@ -105,56 +105,30 @@ pub fn process_deploy(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResul .as_account_mut::(&ore_api::ID)? .assert_mut(|m| { if let Some(automation) = &automation { - // only run automation once per round m.authority == automation.authority - && (m.round_id < board.id || board.slot_hash != [0; 32]) } else { m.authority == *signer_info.key } })? }; - // Reset board. - if board.slot_hash != [0; 32] { - // Reset board - board.deployed = [0; 25]; - board.id += 1; - board.slot_hash = [0; 32]; - board.start_slot = clock.slot; - board.end_slot = clock.slot + 150; // one minute - board.top_miner = Pubkey::default(); - board.total_deployed = 0; - board.total_vaulted = 0; - board.total_winnings = 0; - - // Reset squares - square.count = [0; 25]; - square.deployed = [[0; 16]; 25]; - square.miners = [[Pubkey::default(); 16]; 25]; - } - // Reset miner - if miner.round_id != board.id { - miner.deployed = [0; 25]; - miner.round_id = board.id; - } + if miner.round_id != round.id { + // Assert miner has checkpointed prior round. + assert!( + miner.checkpoint_id == miner.round_id, + "Miner has not checkpointed" + ); - // Close early if estimated automation balance is less than required. - if let Some(automation) = &automation { - let num_squares = squares.iter().filter(|&&s| s).count(); - let estimated_cost = (num_squares as u64 * amount) + automation.fee; - if estimated_cost > automation.balance { - sol_log("Automation balance too low."); - automation_info.close(authority_info)?; - return Ok(()); - } + // Reset miner for new round. + miner.deployed = [0; 25]; + miner.cumulative = round.deployed; + miner.round_id = round.id; } // Calculate all deployments. - let mut refund_amounts = [0; 25]; - let mut refund_miner_infos = [None; 25]; let mut total_amount = 0; - 'deploy: for (square_id, &should_deploy) in squares.iter().enumerate() { + for (square_id, &should_deploy) in squares.iter().enumerate() { // Skip if square index is out of bounds. if square_id > 24 { break; @@ -165,118 +139,36 @@ pub fn process_deploy(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResul continue; } - // Get deployment metadata. - let is_first_move = miner.deployed[square_id] == 0; - let mut idx = if is_first_move { - // Insert at end of the list. - square.count[square_id] as usize - } else { - // Find the miner's index in the list. - let mut found = false; - let mut idx = 0; - for i in 0..16 { - if square.miners[square_id][i] == miner.authority { - idx = i; - found = true; - break; - } - } - - // Safety check. - // This should never happen. - assert!(found); - - idx - }; - - // If the square is full, refund the miner with the smallest deployment and kick them off the square. - if idx == 16 { - // Find miner with the smallest deployment. - let mut smallest_miner = Pubkey::default(); - let mut smallest_deployment = u64::MAX; - for i in 0..16 { - if square.deployed[square_id][i] < smallest_deployment { - smallest_deployment = square.deployed[square_id][i]; - smallest_miner = square.miners[square_id][i]; - idx = i; - } - } - - // Safety check. - // This should never happen. - assert!(smallest_miner != Pubkey::default()); - - // If deploy amount is less than smallest deployment, skip this square. - if amount < smallest_deployment { - continue 'deploy; - } - - // Refund the smallest miner and kick them off the square. - 'refund: for miner_info in miner_accounts { - if *miner_info.key == miner_pda(smallest_miner).0 { - // Refund the smallest miner. - let smallest_miner = miner_info - .as_account_mut::(&ore_api::ID)? - .assert_mut(|m| m.authority == smallest_miner)?; - smallest_miner.refund_sol += smallest_deployment; - smallest_miner.deployed[square_id] -= smallest_deployment; - refund_amounts[square_id] = smallest_deployment; - refund_miner_infos[square_id] = Some(miner_info); - - // Kick the smallest miner from the square. - board.deployed[square_id] -= smallest_deployment; - board.total_deployed -= smallest_deployment; - square.deployed[square_id][idx] -= smallest_deployment; - square.miners[square_id][idx] = Pubkey::default(); - square.count[square_id] -= 1; - - sol_log( - &format!( - "Kicking miner {} from square {}. Refunding: {}", - smallest_miner.authority, square_id, smallest_deployment - ) - .as_str(), - ); - break 'refund; - } - } + // Skip if miner already deployed to this square. + if miner.deployed[square_id] > 0 { + continue; } - // Safety check. - // This should never happen. - assert!(idx < 16); - - // Safety check. - // Skip if square count is still >= 16. This can only happen if the signer didn't provide a miner account to refund. - if square.count[square_id] >= 16 { - sol_log(&format!("Square {} is full. Skipping deployment.", square_id).as_str()); - continue 'deploy; - } + // Record cumulative amount. + miner.cumulative[square_id] = round.deployed[square_id]; // Update miner - miner.deployed[square_id] += amount; - - // Update square - if is_first_move { - square.miners[square_id][idx] = miner.authority; - square.count[square_id] += 1; - } + miner.deployed[square_id] = amount; // Update board - board.deployed[square_id] += amount; - board.total_deployed += amount; - - // Update square deployed - square.deployed[square_id][idx] += amount; + round.deployed[square_id] += amount; + round.total_deployed += amount; // Update total amount total_amount += amount; + + // Exit early if automation does not have enough balance for another square. + if let Some(automation) = &automation { + if total_amount + automation.fee + amount > automation.balance { + break; + } + } } // Transfer SOL. if let Some(automation) = automation { automation.balance -= total_amount + automation.fee; - automation_info.send(total_amount, &board_info); + automation_info.send(total_amount, &round_info); automation_info.send(automation.fee, &signer_info); // Close automation if balance is 0. @@ -284,14 +176,7 @@ pub fn process_deploy(accounts: &[AccountInfo<'_>], data: &[u8]) -> ProgramResul automation_info.close(authority_info)?; } } else { - board_info.collect(total_amount, &signer_info)?; - } - - // Transfer SOL refunds. - for (square_id, refund_amount) in refund_amounts.iter().enumerate() { - if let Some(refund_miner_info) = refund_miner_infos[square_id] { - board_info.send(*refund_amount, &refund_miner_info); - } + round_info.collect(total_amount, &signer_info)?; } Ok(()) diff --git a/program/src/lib.rs b/program/src/lib.rs index 051907b..61c556f 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -1,38 +1,40 @@ -// mod automate; -// mod boost; -// mod bury; -// mod claim_ore; -// mod claim_seeker; -// mod claim_sol; -// mod claim_yield; -// mod deploy; -// mod deposit; +mod automate; +mod boost; +mod bury; +mod checkpoint; +mod claim_ore; +mod claim_seeker; +mod claim_sol; +mod claim_yield; +mod deploy; +mod deposit; // mod initialize; -// mod log; -// mod reset; -// mod set_admin; -// mod set_fee_collector; -// mod whitelist; -// mod withdraw; -// mod wrap; +mod log; +mod reset; +mod set_admin; +mod set_fee_collector; +mod whitelist; +mod withdraw; +mod wrap; -// use automate::*; -// use boost::*; -// use bury::*; -// use claim_ore::*; -// use claim_seeker::*; -// use claim_sol::*; -// use claim_yield::*; -// use deploy::*; -// use deposit::*; +use automate::*; +use boost::*; +use bury::*; +use checkpoint::*; +use claim_ore::*; +use claim_seeker::*; +use claim_sol::*; +use claim_yield::*; +use deploy::*; +use deposit::*; // use initialize::*; -// use log::*; -// use reset::*; -// use set_admin::*; -// use set_fee_collector::*; -// use whitelist::*; -// use withdraw::*; -// use wrap::*; +use log::*; +use reset::*; +use set_admin::*; +use set_fee_collector::*; +use whitelist::*; +use withdraw::*; +use wrap::*; use ore_api::instruction::*; use steel::*;