diff --git a/program/c/src/oracle/oracle.h b/program/c/src/oracle/oracle.h index 29fad932e..320d1e84e 100644 --- a/program/c/src/oracle/oracle.h +++ b/program/c/src/oracle/oracle.h @@ -269,6 +269,12 @@ typedef enum { // key[1] price account [Signer writable] // key[2] system program [readable] e_cmd_resize_price_account, + + // deletes a price account + // key[0] funding account [signer writable] + // key[1] product account [signer writable] + // key[2] price account [signer writable] + e_cmd_del_price, } command_t; typedef struct cmd_hdr diff --git a/program/rust/Cargo.toml b/program/rust/Cargo.toml index e823ffe91..a368063ac 100644 --- a/program/rust/Cargo.toml +++ b/program/rust/Cargo.toml @@ -13,6 +13,11 @@ solana-program = "=1.10.29" bytemuck = "1.11.0" thiserror = "1.0" +[dev-dependencies] +solana-program-test = "=1.10.29" +solana-sdk = "=1.10.29" +tokio = "1.14.1" + [features] debug = [] diff --git a/program/rust/src/processor.rs b/program/rust/src/processor.rs index 28f734fd2..595f1e915 100644 --- a/program/rust/src/processor.rs +++ b/program/rust/src/processor.rs @@ -5,6 +5,7 @@ use crate::c_oracle_header::{ command_t_e_cmd_add_product, command_t_e_cmd_add_publisher, command_t_e_cmd_agg_price, + command_t_e_cmd_del_price, command_t_e_cmd_del_publisher, command_t_e_cmd_init_mapping, command_t_e_cmd_init_price, @@ -27,6 +28,7 @@ use crate::rust_oracle::{ add_price, add_product, add_publisher, + del_price, del_publisher, init_mapping, init_price, @@ -72,6 +74,7 @@ pub fn process_instruction( command_t_e_cmd_add_product => add_product(program_id, accounts, instruction_data), command_t_e_cmd_upd_product => upd_product(program_id, accounts, instruction_data), command_t_e_cmd_set_min_pub => set_min_pub(program_id, accounts, instruction_data), + command_t_e_cmd_del_price => del_price(program_id, accounts, instruction_data), _ => Err(OracleError::UnrecognizedInstruction.into()), } } diff --git a/program/rust/src/rust_oracle.rs b/program/rust/src/rust_oracle.rs index c44ec9faf..3bf306b1e 100644 --- a/program/rust/src/rust_oracle.rs +++ b/program/rust/src/rust_oracle.rs @@ -10,6 +10,7 @@ use bytemuck::{ use solana_program::account_info::AccountInfo; use solana_program::clock::Clock; use solana_program::entrypoint::ProgramResult; +use solana_program::program::invoke; use solana_program::program_error::ProgramError; use solana_program::program_memory::{ sol_memcpy, @@ -17,14 +18,9 @@ use solana_program::program_memory::{ }; use solana_program::pubkey::Pubkey; use solana_program::rent::Rent; -use solana_program::sysvar::Sysvar; - - -use crate::time_machine_types::PriceAccountWrapper; -use solana_program::program::invoke; use solana_program::system_instruction::transfer; use solana_program::system_program::check_id; - +use solana_program::sysvar::Sysvar; use crate::c_oracle_header::{ cmd_add_price_t, @@ -57,8 +53,7 @@ use crate::deserialize::{ load_account_as_mut, load_checked, }; -use crate::OracleError; - +use crate::time_machine_types::PriceAccountWrapper; use crate::utils::{ check_exponent_range, check_valid_fresh_account, @@ -73,6 +68,7 @@ use crate::utils::{ read_pc_str_t, try_convert, }; +use crate::OracleError; const PRICE_T_SIZE: usize = size_of::(); const PRICE_ACCOUNT_SIZE: usize = size_of::(); @@ -371,6 +367,54 @@ pub fn add_price( Ok(()) } +/// Delete a price account. This function will remove the link between the price account and its +/// corresponding product account, then transfer any SOL in the price account to the funding +/// account. This function can only delete the first price account in the linked list of +/// price accounts for the given product. +/// +/// Warning: This function is dangerous and will break any programs that depend on the deleted +/// price account! +pub fn del_price( + program_id: &Pubkey, + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> ProgramResult { + let [funding_account, product_account, price_account] = match accounts { + [w, x, y] => Ok([w, x, y]), + _ => Err(ProgramError::InvalidArgument), + }?; + + check_valid_funding_account(funding_account)?; + check_valid_signable_account(program_id, product_account, PC_PROD_ACC_SIZE as usize)?; + check_valid_signable_account(program_id, price_account, size_of::())?; + + { + let cmd_args = load::(instruction_data)?; + let mut product_data = load_checked::(product_account, cmd_args.ver_)?; + let price_data = load_checked::(price_account, cmd_args.ver_)?; + pyth_assert( + pubkey_equal(&product_data.px_acc_, &price_account.key.to_bytes()), + ProgramError::InvalidArgument, + )?; + + pyth_assert( + pubkey_equal(&price_data.prod_, &product_account.key.to_bytes()), + ProgramError::InvalidArgument, + )?; + + pubkey_assign(&mut product_data.px_acc_, bytes_of(&price_data.next_)); + } + + // Zero out the balance of the price account to delete it. + // Note that you can't use the system program's transfer instruction to do this operation, as + // that instruction fails if the source account has any data. + let lamports = price_account.lamports(); + **price_account.lamports.borrow_mut() = 0; + **funding_account.lamports.borrow_mut() += lamports; + + Ok(()) +} + pub fn init_price( program_id: &Pubkey, accounts: &[AccountInfo], diff --git a/program/rust/src/tests/mod.rs b/program/rust/src/tests/mod.rs index 1274dfd1f..164ebcf80 100644 --- a/program/rust/src/tests/mod.rs +++ b/program/rust/src/tests/mod.rs @@ -1,7 +1,9 @@ +mod pyth_simulator; mod test_add_mapping; mod test_add_price; mod test_add_product; mod test_add_publisher; +mod test_del_price; mod test_del_publisher; mod test_init_mapping; mod test_init_price; diff --git a/program/rust/src/tests/pyth_simulator.rs b/program/rust/src/tests/pyth_simulator.rs new file mode 100644 index 000000000..194c9336f --- /dev/null +++ b/program/rust/src/tests/pyth_simulator.rs @@ -0,0 +1,232 @@ +use std::mem::size_of; + +use bytemuck::{ + bytes_of, + Pod, +}; +use solana_program::hash::Hash; +use solana_program::instruction::{ + AccountMeta, + Instruction, +}; +use solana_program::pubkey::Pubkey; +use solana_program::rent::Rent; +use solana_program::system_instruction; +use solana_program_test::{ + processor, + BanksClient, + BanksClientError, + ProgramTest, + ProgramTestBanksClientExt, +}; +use solana_sdk::account::Account; +use solana_sdk::signature::{ + Keypair, + Signer, +}; +use solana_sdk::transaction::Transaction; + +use crate::c_oracle_header::{ + cmd_add_price_t, + cmd_hdr_t, + command_t_e_cmd_add_price, + command_t_e_cmd_add_product, + command_t_e_cmd_del_price, + command_t_e_cmd_init_mapping, + pc_map_table_t, + pc_price_t, + PC_PROD_ACC_SIZE, + PC_PTYPE_PRICE, + PC_VERSION, +}; +use crate::deserialize::load; +use crate::processor::process_instruction; + +/// Simulator for the state of the pyth program on Solana. You can run solana transactions against +/// this struct to test how pyth instructions execute in the Solana runtime. +pub struct PythSimulator { + program_id: Pubkey, + banks_client: BanksClient, + payer: Keypair, + /// Hash used to submit the last transaction. The hash must be advanced for each new + /// transaction; otherwise, replayed transactions in different states can return stale + /// results. + last_blockhash: Hash, +} + +impl PythSimulator { + pub async fn new() -> PythSimulator { + let program_id = Pubkey::new_unique(); + let (banks_client, payer, recent_blockhash) = + ProgramTest::new("pyth_oracle", program_id, processor!(process_instruction)) + .start() + .await; + + PythSimulator { + program_id, + banks_client, + payer, + last_blockhash: recent_blockhash, + } + } + + /// Process a transaction containing `instruction` signed by `signers`. + /// The transaction is assumed to require `self.payer` to pay for and sign the transaction. + async fn process_ix( + &mut self, + instruction: Instruction, + signers: &Vec<&Keypair>, + ) -> Result<(), BanksClientError> { + let mut transaction = + Transaction::new_with_payer(&[instruction], Some(&self.payer.pubkey())); + + let blockhash = self + .banks_client + .get_new_latest_blockhash(&self.last_blockhash) + .await + .unwrap(); + self.last_blockhash = blockhash; + + transaction.partial_sign(&[&self.payer], self.last_blockhash); + transaction.partial_sign(signers, self.last_blockhash); + + self.banks_client.process_transaction(transaction).await + } + + /// Create an account owned by the pyth program containing `size` bytes. + /// The account will be created with enough lamports to be rent-exempt. + pub async fn create_pyth_account(&mut self, size: usize) -> Keypair { + let keypair = Keypair::new(); + let rent = Rent::minimum_balance(&Rent::default(), size); + let instruction = system_instruction::create_account( + &self.payer.pubkey(), + &keypair.pubkey(), + rent, + size as u64, + &self.program_id, + ); + + self.process_ix(instruction, &vec![&keypair]).await.unwrap(); + + keypair + } + + /// Initialize a mapping account (using the init_mapping instruction), returning the keypair + /// associated with the newly-created account. + pub async fn init_mapping(&mut self) -> Result { + let mapping_keypair = self.create_pyth_account(size_of::()).await; + + let cmd = cmd_hdr_t { + ver_: PC_VERSION, + cmd_: command_t_e_cmd_init_mapping as i32, + }; + let instruction = Instruction::new_with_bytes( + self.program_id, + bytes_of(&cmd), + vec![ + AccountMeta::new(self.payer.pubkey(), true), + AccountMeta::new(mapping_keypair.pubkey(), true), + ], + ); + + self.process_ix(instruction, &vec![&mapping_keypair]) + .await + .map(|_| mapping_keypair) + } + + /// Initialize a product account and add it to an existing mapping account (using the + /// add_product instruction). Returns the keypair associated with the newly-created account. + pub async fn add_product( + &mut self, + mapping_keypair: &Keypair, + ) -> Result { + let product_keypair = self.create_pyth_account(PC_PROD_ACC_SIZE as usize).await; + + let cmd = cmd_hdr_t { + ver_: PC_VERSION, + cmd_: command_t_e_cmd_add_product as i32, + }; + let instruction = Instruction::new_with_bytes( + self.program_id, + bytes_of(&cmd), + vec![ + AccountMeta::new(self.payer.pubkey(), true), + AccountMeta::new(mapping_keypair.pubkey(), true), + AccountMeta::new(product_keypair.pubkey(), true), + ], + ); + + self.process_ix(instruction, &vec![&mapping_keypair, &product_keypair]) + .await + .map(|_| product_keypair) + } + + /// Initialize a price account and add it to an existing product account (using the add_price + /// instruction). Returns the keypair associated with the newly-created account. + pub async fn add_price( + &mut self, + product_keypair: &Keypair, + expo: i32, + ) -> Result { + let price_keypair = self.create_pyth_account(size_of::()).await; + + let cmd = cmd_add_price_t { + ver_: PC_VERSION, + cmd_: command_t_e_cmd_add_price as i32, + expo_: expo, + ptype_: PC_PTYPE_PRICE, + }; + let instruction = Instruction::new_with_bytes( + self.program_id, + bytes_of(&cmd), + vec![ + AccountMeta::new(self.payer.pubkey(), true), + AccountMeta::new(product_keypair.pubkey(), true), + AccountMeta::new(price_keypair.pubkey(), true), + ], + ); + + self.process_ix(instruction, &vec![&product_keypair, &price_keypair]) + .await + .map(|_| price_keypair) + } + + /// Delete a price account from an existing product account (using the del_price instruction). + pub async fn del_price( + &mut self, + product_keypair: &Keypair, + price_keypair: &Keypair, + ) -> Result<(), BanksClientError> { + let cmd = cmd_hdr_t { + ver_: PC_VERSION, + cmd_: command_t_e_cmd_del_price as i32, + }; + let instruction = Instruction::new_with_bytes( + self.program_id, + bytes_of(&cmd), + vec![ + AccountMeta::new(self.payer.pubkey(), true), + AccountMeta::new(product_keypair.pubkey(), true), + AccountMeta::new(price_keypair.pubkey(), true), + ], + ); + + self.process_ix(instruction, &vec![&product_keypair, &price_keypair]) + .await + } + + /// Get the account at `key`. Returns `None` if no such account exists. + pub async fn get_account(&mut self, key: Pubkey) -> Option { + self.banks_client.get_account(key).await.unwrap() + } + + /// Get the content of an account as a value of type `T`. This function returns a copy of the + /// account data -- you cannot mutate the result to mutate the on-chain account data. + /// Returns None if the account does not exist. Panics if the account data cannot be read as a + /// `T`. + pub async fn get_account_data_as(&mut self, key: Pubkey) -> Option { + self.get_account(key) + .await + .map(|x| load::(&x.data).unwrap().clone()) + } +} diff --git a/program/rust/src/tests/test_del_price.rs b/program/rust/src/tests/test_del_price.rs new file mode 100644 index 000000000..8f2e66153 --- /dev/null +++ b/program/rust/src/tests/test_del_price.rs @@ -0,0 +1,52 @@ +use solana_sdk::signer::Signer; + +use crate::c_oracle_header::pc_prod_t; +use crate::tests::pyth_simulator::PythSimulator; +use crate::utils::pubkey_is_zero; + +#[tokio::test] +async fn test_del_price() { + let mut sim = PythSimulator::new().await; + let mapping_keypair = sim.init_mapping().await.unwrap(); + let product1 = sim.add_product(&mapping_keypair).await.unwrap(); + let product2 = sim.add_product(&mapping_keypair).await.unwrap(); + let product3 = sim.add_product(&mapping_keypair).await.unwrap(); + let price1 = sim.add_price(&product1, -8).await.unwrap(); + let price2_1 = sim.add_price(&product2, -8).await.unwrap(); + let price2_2 = sim.add_price(&product2, -8).await.unwrap(); + + assert!(sim.get_account(price1.pubkey()).await.is_some()); + assert!(sim.get_account(price2_1.pubkey()).await.is_some()); + + assert!(sim.del_price(&product2, &price1).await.is_err()); + assert!(sim.del_price(&product1, &price2_1).await.is_err()); + assert!(sim.del_price(&product1, &price2_2).await.is_err()); + assert!(sim.del_price(&product3, &price2_1).await.is_err()); + assert!(sim.del_price(&product3, &price2_2).await.is_err()); + + sim.del_price(&product1, &price1).await.unwrap(); + assert!(sim.get_account(price1.pubkey()).await.is_none()); + + let product1_data = sim + .get_account_data_as::(product1.pubkey()) + .await + .unwrap(); + assert!(pubkey_is_zero(&product1_data.px_acc_)); + + + // price2_1 is the 2nd item in the linked list since price2_2 got added after t. + assert!(sim.del_price(&product2, &price2_1).await.is_err()); + // Can delete the accounts in the opposite order though + assert!(sim.del_price(&product2, &price2_2).await.is_ok()); + assert!(sim.del_price(&product2, &price2_1).await.is_ok()); + + assert!(sim.get_account(price2_2.pubkey()).await.is_none()); + assert!(sim.get_account(price2_1.pubkey()).await.is_none()); + + let product2_data = sim + .get_account_data_as::(product2.pubkey()) + .await + .unwrap(); + + assert!(pubkey_is_zero(&product2_data.px_acc_)); +} diff --git a/program/rust/src/time_machine_types.rs b/program/rust/src/time_machine_types.rs index bccc1e0c3..854d3c5af 100644 --- a/program/rust/src/time_machine_types.rs +++ b/program/rust/src/time_machine_types.rs @@ -75,7 +75,7 @@ pub mod tests { fn c_time_machine_size_is_correct() { assert_eq!( size_of::(), - TIME_MACHINE_STRUCT_SIZE.try_into().unwrap(), + TIME_MACHINE_STRUCT_SIZE as usize, "expected TIME_MACHINE_STRUCT_SIZE ({}) in oracle.h to the same as the size of TimeMachineWrapper ({})", TIME_MACHINE_STRUCT_SIZE, size_of::() @@ -86,7 +86,7 @@ pub mod tests { fn c_price_account_size_is_correct() { assert_eq!( size_of::(), - PRICE_ACCOUNT_SIZE.try_into().unwrap(), + PRICE_ACCOUNT_SIZE as usize, "expected PRICE_ACCOUNT_SIZE ({}) in oracle.h to the same as the size of PriceAccountWrapper ({})", PRICE_ACCOUNT_SIZE, size_of::()