From 9693677238c1cbbca82f4bfb990cc50c1fd74ab2 Mon Sep 17 00:00:00 2001 From: guibescos <59208140+guibescos@users.noreply.github.com> Date: Wed, 8 Nov 2023 19:19:10 +0100 Subject: [PATCH] Authority timelock (#3) * Authority timelock * Cleanup * Clippy * Comment * Renames --- Cargo.lock | 11 + .../program-authority-timelock/Cargo.toml | 25 ++ .../program-authority-timelock/src/lib.rs | 119 +++++++ .../src/tests/mod.rs | 2 + .../src/tests/simulator.rs | 292 ++++++++++++++++++ .../src/tests/test.rs | 110 +++++++ rust-toolchain | 5 + 7 files changed, 564 insertions(+) create mode 100644 programs/program-authority-timelock/Cargo.toml create mode 100644 programs/program-authority-timelock/src/lib.rs create mode 100644 programs/program-authority-timelock/src/tests/mod.rs create mode 100644 programs/program-authority-timelock/src/tests/simulator.rs create mode 100644 programs/program-authority-timelock/src/tests/test.rs create mode 100644 rust-toolchain diff --git a/Cargo.lock b/Cargo.lock index d9c1728..0f7fee0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2542,6 +2542,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "program-authority-timelock" +version = "1.0.0" +dependencies = [ + "anchor-lang", + "bincode", + "solana-program-test", + "solana-sdk", + "tokio", +] + [[package]] name = "qstring" version = "0.7.2" diff --git a/programs/program-authority-timelock/Cargo.toml b/programs/program-authority-timelock/Cargo.toml new file mode 100644 index 0000000..d09fa68 --- /dev/null +++ b/programs/program-authority-timelock/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "program-authority-timelock" +version = "1.0.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "program_authority_timelock" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] + +[dependencies] +anchor-lang = "0.26.0" + +[dev-dependencies] +solana-program-test = "=1.14.7" +solana-sdk = "=1.14.7" +tokio = "1.14.1" +bincode = "1.3.3" \ No newline at end of file diff --git a/programs/program-authority-timelock/src/lib.rs b/programs/program-authority-timelock/src/lib.rs new file mode 100644 index 0000000..fddee7e --- /dev/null +++ b/programs/program-authority-timelock/src/lib.rs @@ -0,0 +1,119 @@ +#![deny(warnings)] +#![allow(clippy::result_large_err)] + +use anchor_lang::{ + prelude::*, + solana_program::{ + bpf_loader_upgradeable, + program::{ + invoke, + invoke_signed, + }, + }, +}; + +#[cfg(test)] +mod tests; + +declare_id!("escMHe7kSqPcDHx4HU44rAHhgdTLBZkUrU39aN8kMcL"); +const ONE_YEAR: i64 = 365 * 24 * 60 * 60; + +#[program] +pub mod program_authority_timelock { + use super::*; + + pub fn commit(ctx: Context, timestamp: i64) -> Result<()> { + let current_authority = &ctx.accounts.current_authority; + let escrow_authority = &ctx.accounts.escrow_authority; + let program_account = &ctx.accounts.program_account; + + invoke( + &bpf_loader_upgradeable::set_upgrade_authority( + &program_account.key(), + ¤t_authority.key(), + Some(&escrow_authority.key()), + ), + &ctx.accounts.to_account_infos(), + )?; + + // Check that the timelock is no longer than 1 year + if Clock::get()?.unix_timestamp.saturating_add(ONE_YEAR) < timestamp { + return Err(ErrorCode::TimestampTooLate.into()); + } + + Ok(()) + } + + pub fn transfer(ctx: Context, timestamp: i64) -> Result<()> { + let new_authority = &ctx.accounts.new_authority; + let escrow_authority = &ctx.accounts.escrow_authority; + let program_account = &ctx.accounts.program_account; + + invoke_signed( + &bpf_loader_upgradeable::set_upgrade_authority( + &program_account.key(), + &escrow_authority.key(), + Some(&new_authority.key()), + ), + &ctx.accounts.to_account_infos(), + &[&[ + new_authority.key().as_ref(), + timestamp.to_be_bytes().as_ref(), + &[*ctx.bumps.get("escrow_authority").unwrap()], + ]], + )?; + + if Clock::get()?.unix_timestamp < timestamp { + return Err(ErrorCode::TimestampTooEarly.into()); + } + + Ok(()) + } +} + +#[derive(Accounts)] +#[instruction(timestamp : i64)] +pub struct Commit<'info> { + pub current_authority: Signer<'info>, + /// CHECK: Unchecked new authority, can be a native wallet or a PDA of another program + pub new_authority: AccountInfo<'info>, + #[account(seeds = [new_authority.key().as_ref(), timestamp.to_be_bytes().as_ref()], bump)] + pub escrow_authority: SystemAccount<'info>, + #[account(executable, constraint = matches!(program_account.as_ref(), UpgradeableLoaderState::Program{..}))] + pub program_account: Account<'info, UpgradeableLoaderState>, + #[account(mut, seeds = [program_account.key().as_ref()], bump, seeds::program = bpf_upgradable_loader.key())] + pub program_data: Account<'info, ProgramData>, + pub bpf_upgradable_loader: Program<'info, BpfUpgradableLoader>, +} + +#[derive(Accounts)] +#[instruction(timestamp : i64)] +pub struct Transfer<'info> { + /// CHECK: Unchecked new authority, can be a native wallet or a PDA of another program + pub new_authority: AccountInfo<'info>, + #[account(seeds = [new_authority.key().as_ref(), timestamp.to_be_bytes().as_ref()], bump)] + pub escrow_authority: SystemAccount<'info>, + #[account(executable, constraint = matches!(program_account.as_ref(), UpgradeableLoaderState::Program{..}))] + pub program_account: Account<'info, UpgradeableLoaderState>, + #[account(mut, seeds = [program_account.key().as_ref()], bump, seeds::program = bpf_upgradable_loader.key())] + pub program_data: Account<'info, ProgramData>, + pub bpf_upgradable_loader: Program<'info, BpfUpgradableLoader>, +} + +#[derive(Clone)] +pub struct BpfUpgradableLoader {} + +impl Id for BpfUpgradableLoader { + fn id() -> Pubkey { + bpf_loader_upgradeable::id() + } +} + +#[error_code] +#[derive(PartialEq, Eq)] +pub enum ErrorCode { + #[msg("Timestamp too early")] + TimestampTooEarly, + #[msg("Timestamp too late")] + TimestampTooLate, +} diff --git a/programs/program-authority-timelock/src/tests/mod.rs b/programs/program-authority-timelock/src/tests/mod.rs new file mode 100644 index 0000000..03cbd27 --- /dev/null +++ b/programs/program-authority-timelock/src/tests/mod.rs @@ -0,0 +1,2 @@ +mod simulator; +mod test; diff --git a/programs/program-authority-timelock/src/tests/simulator.rs b/programs/program-authority-timelock/src/tests/simulator.rs new file mode 100644 index 0000000..43345a2 --- /dev/null +++ b/programs/program-authority-timelock/src/tests/simulator.rs @@ -0,0 +1,292 @@ +use { + crate::instruction, + anchor_lang::{ + prelude::{ + Clock, + Pubkey, + Rent, + UpgradeableLoaderState, + }, + AccountDeserialize, + InstructionData, + ProgramData, + ToAccountMetas, + }, + solana_program_test::{ + read_file, + BanksClientError, + ProgramTest, + ProgramTestContext, + ProgramTestError, + }, + solana_sdk::{ + account::Account, + bpf_loader_upgradeable, + instruction::Instruction, + signature::{ + Keypair, + Signer, + }, + stake_history::Epoch, + transaction::Transaction, + }, + std::path::PathBuf, +}; + + +pub struct TimelockSimulator { + context: ProgramTestContext, + helloworld_address: Pubkey, + timelock_address: Pubkey, +} + +impl TimelockSimulator { + pub async fn new() -> (TimelockSimulator, Keypair) { + let mut bpf_data = read_file(PathBuf::from("../../tests/fixtures/helloworld.so")); + + let timelock_address = crate::id(); + + let mut program_test = + ProgramTest::new("program_authority_timelock", timelock_address, None); + let upgrade_authority = Keypair::new(); + + let helloworld_address = add_program_as_upgradable( + &mut bpf_data, + &upgrade_authority.pubkey(), + &mut program_test, + ); + + let context = program_test.start_with_context().await; + + ( + TimelockSimulator { + context, + helloworld_address, + timelock_address, + }, + upgrade_authority, + ) + } +} + +pub fn add_program_as_upgradable( + data: &mut Vec, + upgrade_authority: &Pubkey, + program_test: &mut ProgramTest, +) -> Pubkey { + let program_key = Pubkey::new_unique(); + let (programdata_key, _) = + Pubkey::find_program_address(&[&program_key.to_bytes()], &bpf_loader_upgradeable::id()); + + + let program_deserialized = UpgradeableLoaderState::Program { + programdata_address: programdata_key, + }; + let programdata_deserialized = UpgradeableLoaderState::ProgramData { + slot: 1, + upgrade_authority_address: Some(*upgrade_authority), + }; + + // Program contains a pointer to progradata + let program_vec = bincode::serialize(&program_deserialized).unwrap(); + // Programdata contains a header and the binary of the program + let mut programdata_vec = bincode::serialize(&programdata_deserialized).unwrap(); + programdata_vec.append(data); + + let program_account = Account { + lamports: Rent::default().minimum_balance(program_vec.len()), + data: program_vec, + owner: bpf_loader_upgradeable::ID, + executable: true, + rent_epoch: Epoch::default(), + }; + let programdata_account = Account { + lamports: Rent::default().minimum_balance(programdata_vec.len()), + data: programdata_vec, + owner: bpf_loader_upgradeable::ID, + executable: false, + rent_epoch: Epoch::default(), + }; + + // Add both accounts to program test, now the program is deployed as upgradable + program_test.add_account(program_key, program_account); + program_test.add_account(programdata_key, programdata_account); + + program_key +} + + +impl TimelockSimulator { + async fn process_ix( + &mut self, + instruction: Instruction, + signers: &Vec<&Keypair>, + ) -> Result<(), BanksClientError> { + let mut transaction = + Transaction::new_with_payer(&[instruction], Some(&self.context.payer.pubkey())); + + let blockhash = self + .context + .banks_client + .get_latest_blockhash() + .await + .unwrap(); + + transaction.partial_sign(&[&self.context.payer], blockhash); + transaction.partial_sign(signers, blockhash); + self.context + .banks_client + .process_transaction(transaction) + .await + } + + pub async fn commit( + &mut self, + current_authority_keypair: &Keypair, + new_authority: &Pubkey, + timestamp: i64, + ) -> Result<(), BanksClientError> { + let account_metas = crate::accounts::Commit::create( + ¤t_authority_keypair.pubkey(), + new_authority, + &self.helloworld_address, + &self.timelock_address, + timestamp, + ) + .to_account_metas(None); + + let instruction = Instruction { + program_id: self.timelock_address, + accounts: account_metas, + data: instruction::Commit { timestamp }.data(), + }; + + self.process_ix(instruction, &vec![current_authority_keypair]) + .await + } + + pub async fn transfer( + &mut self, + new_authority: &Pubkey, + timestamp: i64, + ) -> Result<(), BanksClientError> { + let account_metas = crate::accounts::Transfer::create( + new_authority, + &self.helloworld_address, + &self.timelock_address, + timestamp, + ) + .to_account_metas(None); + + let instruction = Instruction { + program_id: self.timelock_address, + accounts: account_metas, + data: instruction::Transfer { timestamp }.data(), + }; + + self.process_ix(instruction, &vec![]).await + } + + pub async fn get_program_data(&mut self) -> ProgramData { + let program_data = Pubkey::find_program_address( + &[self.helloworld_address.as_ref()], + &bpf_loader_upgradeable::id(), + ) + .0; + + let account = self + .context + .banks_client + .get_account(program_data) + .await + .unwrap() + .unwrap(); + return ProgramData::try_deserialize(&mut account.data.as_slice()).unwrap(); + } + + pub fn get_escrow_authority(&self, new_authority: &Pubkey, timestamp: i64) -> Pubkey { + Pubkey::find_program_address( + &[new_authority.as_ref(), timestamp.to_be_bytes().as_ref()], + &self.timelock_address, + ) + .0 + } + + pub async fn warp_to_timestamp(&mut self, timestamp: i64) -> Result<(), ProgramTestError> { + let current_clock = self + .context + .banks_client + .get_sysvar::() + .await + .unwrap(); + self.context.set_sysvar::(&Clock { + unix_timestamp: timestamp, + ..current_clock + }); + Ok(()) + } + + pub async fn check_program_authority_matches(&mut self, upgrade_authority: &Pubkey) { + let program_data = self.get_program_data().await; + assert_eq!( + program_data.upgrade_authority_address, + Some(*upgrade_authority) + ); + } +} + +impl crate::accounts::Commit { + pub fn create( + current_authority: &Pubkey, + new_authority: &Pubkey, + program_account: &Pubkey, + escrow_address: &Pubkey, + timestamp: i64, + ) -> Self { + let escrow_authority = Pubkey::find_program_address( + &[new_authority.as_ref(), timestamp.to_be_bytes().as_ref()], + escrow_address, + ) + .0; + let program_data = Pubkey::find_program_address( + &[program_account.as_ref()], + &bpf_loader_upgradeable::id(), + ) + .0; + crate::accounts::Commit { + current_authority: *current_authority, + new_authority: *new_authority, + escrow_authority, + program_account: *program_account, + program_data, + bpf_upgradable_loader: bpf_loader_upgradeable::id(), + } + } +} + +impl crate::accounts::Transfer { + pub fn create( + new_authority: &Pubkey, + program_account: &Pubkey, + escrow_address: &Pubkey, + timestamp: i64, + ) -> Self { + let escrow_authority = Pubkey::find_program_address( + &[new_authority.as_ref(), timestamp.to_be_bytes().as_ref()], + escrow_address, + ) + .0; + let program_data = Pubkey::find_program_address( + &[program_account.as_ref()], + &bpf_loader_upgradeable::id(), + ) + .0; + crate::accounts::Transfer { + new_authority: *new_authority, + escrow_authority, + program_account: *program_account, + program_data, + bpf_upgradable_loader: bpf_loader_upgradeable::id(), + } + } +} diff --git a/programs/program-authority-timelock/src/tests/test.rs b/programs/program-authority-timelock/src/tests/test.rs new file mode 100644 index 0000000..54a4348 --- /dev/null +++ b/programs/program-authority-timelock/src/tests/test.rs @@ -0,0 +1,110 @@ +use { + crate::{ + tests::simulator::TimelockSimulator, + ErrorCode, + }, + anchor_lang::prelude::ProgramError, + solana_sdk::{ + instruction::InstructionError, + signature::Keypair, + signer::Signer, + transaction::TransactionError, + }, +}; + +impl From for TransactionError { + fn from(val: ErrorCode) -> Self { + TransactionError::InstructionError( + 0, + InstructionError::try_from(u64::from(ProgramError::from( + anchor_lang::prelude::Error::from(val), + ))) + .unwrap(), + ) + } +} + +#[tokio::test] +async fn test() { + let (mut simulator, authority_keypair_1) = TimelockSimulator::new().await; + let authority_keypair_2 = Keypair::new(); + + simulator + .check_program_authority_matches(&authority_keypair_1.pubkey()) + .await; + + simulator + .commit(&authority_keypair_1, &authority_keypair_2.pubkey(), 0) + .await + .unwrap(); + simulator + .check_program_authority_matches( + &simulator.get_escrow_authority(&authority_keypair_2.pubkey(), 0), + ) + .await; + + simulator + .transfer(&authority_keypair_2.pubkey(), 0) + .await + .unwrap(); + simulator + .check_program_authority_matches(&authority_keypair_2.pubkey()) + .await; + + simulator.warp_to_timestamp(1700000000).await.unwrap(); + + assert_eq!( + simulator + .commit( + &authority_keypair_2, + &authority_keypair_1.pubkey(), + 1700000000 + 365 * 24 * 60 * 60 * 2 + ) + .await + .unwrap_err() + .unwrap(), + ErrorCode::TimestampTooLate.into() + ); + simulator + .check_program_authority_matches(&authority_keypair_2.pubkey()) + .await; + + + simulator + .commit( + &authority_keypair_2, + &authority_keypair_1.pubkey(), + 1700000000 + 30, + ) + .await + .unwrap(); + simulator + .check_program_authority_matches( + &simulator.get_escrow_authority(&authority_keypair_1.pubkey(), 1700000000 + 30), + ) + .await; + + assert_eq!( + simulator + .transfer(&authority_keypair_1.pubkey(), 1700000000 + 30) + .await + .unwrap_err() + .unwrap(), + ErrorCode::TimestampTooEarly.into() + ); + simulator + .check_program_authority_matches( + &simulator.get_escrow_authority(&authority_keypair_1.pubkey(), 1700000000 + 30), + ) + .await; + + simulator.warp_to_timestamp(1700000000 + 31).await.unwrap(); + + simulator + .transfer(&authority_keypair_1.pubkey(), 1700000000 + 30) + .await + .unwrap(); + simulator + .check_program_authority_matches(&authority_keypair_1.pubkey()) + .await; +} diff --git a/rust-toolchain b/rust-toolchain new file mode 100644 index 0000000..fbfc23c --- /dev/null +++ b/rust-toolchain @@ -0,0 +1,5 @@ + +# This is only used for tests +[toolchain] +channel = "1.66.1" +profile = "minimal" \ No newline at end of file