diff --git a/solana/pyth2wormhole/client/src/cli.rs b/solana/pyth2wormhole/client/src/cli.rs index d7e81a7e61..799d8db3c4 100644 --- a/solana/pyth2wormhole/client/src/cli.rs +++ b/solana/pyth2wormhole/client/src/cli.rs @@ -58,6 +58,8 @@ pub enum Action { /// Option<> makes sure not specifying this flag does not imply "false" #[clap(long = "is-active")] is_active: Option, + #[clap(long = "ops-owner")] + ops_owner_addr: Option, }, #[clap( about = "Use an existing pyth2wormhole program to attest product price information to another chain" @@ -115,6 +117,10 @@ pub enum Action { new_pyth_owner_addr: Option, #[clap(long = "is-active")] is_active: Option, + #[clap(long = "ops-owner")] + ops_owner_addr: Option, + #[clap(long = "remove-ops-owner", conflicts_with = "ops_owner_addr")] + remove_ops_owner: bool, }, #[clap( about = "Migrate existing pyth2wormhole program settings to a newer format version. Client version must match the deployed contract." @@ -130,4 +136,21 @@ pub enum Action { }, #[clap(about = "Print out emitter address for the specified pyth2wormhole contract")] GetEmitter, + #[clap( + about = "Set the value of is_active config as ops_owner" + )] + SetIsActive { + /// Current ops owner keypair path + #[clap( + long, + default_value = "~/.config/solana/id.json", + help = "Keypair file for the current ops owner" + )] + ops_owner: String, + #[clap( + index = 1, + possible_values = ["true", "false"], + )] + new_is_active: String, + } } diff --git a/solana/pyth2wormhole/client/src/lib.rs b/solana/pyth2wormhole/client/src/lib.rs index 5f5c432c06..92db445a25 100644 --- a/solana/pyth2wormhole/client/src/lib.rs +++ b/solana/pyth2wormhole/client/src/lib.rs @@ -163,6 +163,45 @@ pub fn gen_set_config_tx( Ok(tx_signed) } + +pub fn gen_set_is_active_tx( + payer: Keypair, + p2w_addr: Pubkey, + ops_owner: Keypair, + new_is_active: bool, + latest_blockhash: Hash, +) -> Result { + let payer_pubkey = payer.pubkey(); + + let acc_metas = vec![ + // config + AccountMeta::new( + P2WConfigAccount::<{ AccountState::Initialized }>::key(None, &p2w_addr), + false, + ), + // ops_owner + AccountMeta::new(ops_owner.pubkey(), true), + // payer + AccountMeta::new(payer.pubkey(), true), + ]; + + let ix_data = ( + pyth2wormhole::instruction::Instruction::SetIsActive, + new_is_active, + ); + + let ix = Instruction::new_with_bytes(p2w_addr, ix_data.try_to_vec()?.as_slice(), acc_metas); + + let signers = vec![&ops_owner, &payer]; + let tx_signed = Transaction::new_signed_with_payer::>( + &[ix], + Some(&payer_pubkey), + &signers, + latest_blockhash, + ); + Ok(tx_signed) +} + pub fn gen_migrate_tx( payer: Keypair, p2w_addr: Pubkey, diff --git a/solana/pyth2wormhole/client/src/main.rs b/solana/pyth2wormhole/client/src/main.rs index 0037093463..c6388abd9f 100644 --- a/solana/pyth2wormhole/client/src/main.rs +++ b/solana/pyth2wormhole/client/src/main.rs @@ -97,6 +97,7 @@ async fn main() -> Result<(), ErrBox> { pyth_owner_addr, wh_prog, is_active, + ops_owner_addr, } => { let tx = gen_init_tx( payer, @@ -107,6 +108,7 @@ async fn main() -> Result<(), ErrBox> { pyth_owner: pyth_owner_addr, is_active: is_active.unwrap_or(true), max_batch_size: P2W_MAX_BATCH_SIZE, + ops_owner: ops_owner_addr, }, latest_blockhash, )?; @@ -114,7 +116,7 @@ async fn main() -> Result<(), ErrBox> { .send_and_confirm_transaction_with_spinner(&tx) .await?; println!( - "Initialized with conifg:\n{:?}", + "Initialized with config:\n{:?}", get_config_account(&rpc_client, &p2w_addr).await? ); } @@ -127,8 +129,17 @@ async fn main() -> Result<(), ErrBox> { new_wh_prog, new_pyth_owner_addr, is_active, + ops_owner_addr, + remove_ops_owner, } => { let old_config = get_config_account(&rpc_client, &p2w_addr).await?; + + let new_ops_owner = if remove_ops_owner { + None + } else { + ops_owner_addr + }; + let tx = gen_set_config_tx( payer, p2w_addr, @@ -139,6 +150,7 @@ async fn main() -> Result<(), ErrBox> { pyth_owner: new_pyth_owner_addr.unwrap_or(old_config.pyth_owner), is_active: is_active.unwrap_or(old_config.is_active), max_batch_size: P2W_MAX_BATCH_SIZE, + ops_owner: new_ops_owner, }, latest_blockhash, )?; @@ -146,7 +158,7 @@ async fn main() -> Result<(), ErrBox> { .send_and_confirm_transaction_with_spinner(&tx) .await?; println!( - "Applied conifg:\n{:?}", + "Applied config:\n{:?}", get_config_account(&rpc_client, &p2w_addr).await? ); } @@ -161,7 +173,7 @@ async fn main() -> Result<(), ErrBox> { .send_and_confirm_transaction_with_spinner(&tx) .await?; println!( - "Applied conifg:\n{:?}", + "Applied config:\n{:?}", get_config_account(&rpc_client, &p2w_addr).await? ); } @@ -196,7 +208,23 @@ async fn main() -> Result<(), ErrBox> { ) .await?; } - Action::GetEmitter => unreachable! {}, + Action::GetEmitter => unreachable! {}, // It is handled early in this function. + Action::SetIsActive { ops_owner, new_is_active } => { + let tx = gen_set_is_active_tx( + payer, + p2w_addr, + read_keypair_file(&*shellexpand::tilde(&ops_owner))?, + new_is_active.eq_ignore_ascii_case("true"), + latest_blockhash, + )?; + rpc_client + .send_and_confirm_transaction_with_spinner(&tx) + .await?; + println!( + "Applied config:\n{:?}", + get_config_account(&rpc_client, &p2w_addr).await? + ); + }, } Ok(()) diff --git a/solana/pyth2wormhole/client/tests/test_attest.rs b/solana/pyth2wormhole/client/tests/test_attest.rs index 426dc4d7f9..f647308116 100644 --- a/solana/pyth2wormhole/client/tests/test_attest.rs +++ b/solana/pyth2wormhole/client/tests/test_attest.rs @@ -47,6 +47,7 @@ async fn test_happy_path() -> Result<(), p2wc::ErrBoxSend> { // Authorities let p2w_owner = Pubkey::new_unique(); let pyth_owner = Pubkey::new_unique(); + let ops_owner = Pubkey::new_unique(); // On-chain state let p2w_config = Pyth2WormholeConfig { @@ -55,6 +56,7 @@ async fn test_happy_path() -> Result<(), p2wc::ErrBoxSend> { max_batch_size: pyth2wormhole::attest::P2W_MAX_BATCH_SIZE, pyth_owner, is_active: true, + ops_owner: Some(ops_owner), }; let bridge_config = BridgeData { diff --git a/solana/pyth2wormhole/client/tests/test_migrate.rs b/solana/pyth2wormhole/client/tests/test_migrate.rs index 80d7dae5f6..016687ed4c 100644 --- a/solana/pyth2wormhole/client/tests/test_migrate.rs +++ b/solana/pyth2wormhole/client/tests/test_migrate.rs @@ -60,6 +60,7 @@ async fn test_migrate_works() -> Result<(), solitaire::ErrBox> { wh_prog: wh_fixture_program_id, max_batch_size: pyth2wormhole::attest::P2W_MAX_BATCH_SIZE, pyth_owner, + is_active: true, }; info!("Before ProgramTest::new()"); @@ -114,6 +115,7 @@ async fn test_migrate_already_migrated() -> Result<(), solitaire::ErrBox> { // Authorities let p2w_owner = Keypair::new(); let pyth_owner = Pubkey::new_unique(); + let ops_owner = Keypair::new(); // On-chain state let old_p2w_config = OldPyth2WormholeConfig { @@ -121,6 +123,7 @@ async fn test_migrate_already_migrated() -> Result<(), solitaire::ErrBox> { wh_prog: wh_fixture_program_id, max_batch_size: pyth2wormhole::attest::P2W_MAX_BATCH_SIZE, pyth_owner, + is_active: true, }; let new_p2w_config = Pyth2WormholeConfig { @@ -129,6 +132,7 @@ async fn test_migrate_already_migrated() -> Result<(), solitaire::ErrBox> { max_batch_size: pyth2wormhole::attest::P2W_MAX_BATCH_SIZE, pyth_owner, is_active: true, + ops_owner: Some(ops_owner.pubkey()), }; info!("Before ProgramTest::new()"); diff --git a/solana/pyth2wormhole/client/tests/test_set_is_active.rs b/solana/pyth2wormhole/client/tests/test_set_is_active.rs new file mode 100644 index 0000000000..6571e47a0e --- /dev/null +++ b/solana/pyth2wormhole/client/tests/test_set_is_active.rs @@ -0,0 +1,177 @@ +pub mod fixtures; + +use borsh::BorshDeserialize; +use p2wc::get_config_account; +use solana_program_test::*; +use solana_sdk::{ + account::Account, + pubkey::Pubkey, + rent::Rent, + signature::Signer, + signer::keypair::Keypair, +}; + +use pyth2wormhole::config::{ + P2WConfigAccount, + Pyth2WormholeConfig, +}; +use pyth2wormhole_client as p2wc; +use solitaire::{ + processors::seeded::Seeded, + AccountState, + BorshSerialize, +}; + +fn clone_keypair(keypair: &Keypair) -> Keypair { + // Unwrap as we are surely copying a keypair and we are in test env. + Keypair::from_bytes(keypair.to_bytes().as_ref()).unwrap() +} + +#[tokio::test] +async fn test_setting_is_active_works() -> Result<(), p2wc::ErrBoxSend> { + // Programs + let p2w_program_id = Pubkey::new_unique(); + let wh_fixture_program_id = Pubkey::new_unique(); + + // Authorities + let p2w_owner = Pubkey::new_unique(); + let pyth_owner = Pubkey::new_unique(); + let ops_owner = Keypair::new(); + + // On-chain state + let p2w_config = Pyth2WormholeConfig { + owner: p2w_owner, + wh_prog: wh_fixture_program_id, + max_batch_size: pyth2wormhole::attest::P2W_MAX_BATCH_SIZE, + pyth_owner, + is_active: true, + ops_owner: Some(ops_owner.pubkey()), + }; + + // Populate test environment + let mut p2w_test = ProgramTest::new( + "pyth2wormhole", + p2w_program_id, + processor!(pyth2wormhole::instruction::solitaire), + ); + + // Plant a filled config account + let p2w_config_bytes = p2w_config.try_to_vec()?; + let p2w_config_account = Account { + lamports: Rent::default().minimum_balance(p2w_config_bytes.len()), + data: p2w_config_bytes, + owner: p2w_program_id, + executable: false, + rent_epoch: 0, + }; + let p2w_config_addr = + P2WConfigAccount::<{ AccountState::Initialized }>::key(None, &p2w_program_id); + + p2w_test.add_account(p2w_config_addr, p2w_config_account); + + let mut ctx = p2w_test.start_with_context().await; + + // Setting to false should work + let set_is_active_false_tx = p2wc::gen_set_is_active_tx( + clone_keypair(&ctx.payer), + p2w_program_id, + clone_keypair(&ops_owner), + false, + ctx.last_blockhash, + ).map_err(|e| e.to_string())?; + + ctx.banks_client.process_transaction(set_is_active_false_tx).await?; + + let config = ctx.banks_client. + get_account_data_with_borsh::(p2w_config_addr).await?; + + assert!(!config.is_active); + + // Setting to true should work + let set_is_active_true_tx = p2wc::gen_set_is_active_tx( + clone_keypair(&ctx.payer), + p2w_program_id, + clone_keypair(&ops_owner), + true, + ctx.last_blockhash, + ).map_err(|e| e.to_string())?; + + ctx.banks_client.process_transaction(set_is_active_true_tx).await?; + + let config = ctx.banks_client. + get_account_data_with_borsh::(p2w_config_addr).await?; + + assert!(config.is_active); + + // A wrong signer cannot handle it + + let set_is_active_true_tx = p2wc::gen_set_is_active_tx( + clone_keypair(&ctx.payer), + p2w_program_id, + clone_keypair(&ctx.payer), + true, + ctx.last_blockhash, + ).map_err(|e| e.to_string())?; + + assert!(ctx.banks_client.process_transaction(set_is_active_true_tx).await.is_err()); + + Ok(()) +} + +#[tokio::test] +async fn test_setting_is_active_does_not_work_without_ops_owner() -> Result<(), p2wc::ErrBoxSend> { + // Programs + let p2w_program_id = Pubkey::new_unique(); + let wh_fixture_program_id = Pubkey::new_unique(); + + // Authorities + let p2w_owner = Pubkey::new_unique(); + let pyth_owner = Keypair::new(); + + // On-chain state + let p2w_config = Pyth2WormholeConfig { + owner: p2w_owner, + wh_prog: wh_fixture_program_id, + max_batch_size: pyth2wormhole::attest::P2W_MAX_BATCH_SIZE, + pyth_owner: pyth_owner.pubkey(), + is_active: true, + ops_owner: None, + }; + + // Populate test environment + let mut p2w_test = ProgramTest::new( + "pyth2wormhole", + p2w_program_id, + processor!(pyth2wormhole::instruction::solitaire), + ); + + // Plant a filled config account + let p2w_config_bytes = p2w_config.try_to_vec()?; + let p2w_config_account = Account { + lamports: Rent::default().minimum_balance(p2w_config_bytes.len()), + data: p2w_config_bytes, + owner: p2w_program_id, + executable: false, + rent_epoch: 0, + }; + let p2w_config_addr = + P2WConfigAccount::<{ AccountState::Initialized }>::key(None, &p2w_program_id); + + p2w_test.add_account(p2w_config_addr, p2w_config_account); + + let mut ctx = p2w_test.start_with_context().await; + + // No one could should be able to handle + // For example pyth_owner is used here. + let set_is_active_true_tx = p2wc::gen_set_is_active_tx( + clone_keypair(&ctx.payer), + p2w_program_id, + pyth_owner, + true, + ctx.last_blockhash, + ).map_err(|e| e.to_string())?; + + assert!(ctx.banks_client.process_transaction(set_is_active_true_tx).await.is_err()); + + Ok(()) +} diff --git a/solana/pyth2wormhole/program/src/config.rs b/solana/pyth2wormhole/program/src/config.rs index 2adc081b72..396275ccc7 100644 --- a/solana/pyth2wormhole/program/src/config.rs +++ b/solana/pyth2wormhole/program/src/config.rs @@ -34,9 +34,10 @@ use solitaire::{ }; /// Aliases for current config schema (to migrate into) -pub type Pyth2WormholeConfig = Pyth2WormholeConfigV2; +pub type Pyth2WormholeConfig = Pyth2WormholeConfigV3; pub type P2WConfigAccount<'b, const IsInitialized: AccountState> = - P2WConfigAccountV2<'b, IsInitialized>; + P2WConfigAccountV3<'b, IsInitialized>; + impl Owned for Pyth2WormholeConfig { fn owner(&self) -> AccountOwner { @@ -45,8 +46,8 @@ impl Owned for Pyth2WormholeConfig { } /// Aliases for previous config schema (to migrate from) -pub type OldPyth2WormholeConfig = Pyth2WormholeConfigV1; -pub type OldP2WConfigAccount<'b> = P2WConfigAccountV1<'b, { AccountState::Initialized }>; // Old config must always be initialized +pub type OldPyth2WormholeConfig = Pyth2WormholeConfigV2; +pub type OldP2WConfigAccount<'b> = P2WConfigAccountV2<'b, { AccountState::Initialized }>; // Old config must always be initialized impl Owned for OldPyth2WormholeConfig { fn owner(&self) -> AccountOwner { @@ -117,3 +118,51 @@ impl From for Pyth2WormholeConfigV2 { } } } + +// Added ops_owner which can toggle the is_active field +#[derive(Clone, Default, BorshDeserialize, BorshSerialize)] +#[cfg_attr(feature = "client", derive(Debug))] +pub struct Pyth2WormholeConfigV3 { + /// Authority owning this contract + pub owner: Pubkey, + /// Wormhole bridge program + pub wh_prog: Pubkey, + /// Authority owning Pyth price data + pub pyth_owner: Pubkey, + /// How many product/price pairs can be sent and attested at once + /// + /// Important: Whenever the corresponding logic in attest.rs + /// changes its expected number of symbols per batch, this config + /// must be updated accordingly on-chain. + pub max_batch_size: u16, + + /// If set to false, attest() will reject all calls unconditionally + pub is_active: bool, + + // If the ops_owner exists, it can toggle the value of `is_active` + pub ops_owner: Option, +} + +pub type P2WConfigAccountV3<'b, const IsInitialized: AccountState> = + Derive, "pyth2wormhole-config-v3">; + +impl From for Pyth2WormholeConfigV3 { + fn from(old: Pyth2WormholeConfigV2) -> Self { + let Pyth2WormholeConfigV2 { + owner, + wh_prog, + pyth_owner, + max_batch_size, + is_active, + } = old; + + Self { + owner, + wh_prog, + pyth_owner, + max_batch_size, + is_active: true, + ops_owner: None + } + } +} diff --git a/solana/pyth2wormhole/program/src/lib.rs b/solana/pyth2wormhole/program/src/lib.rs index e2842dbd15..645abbe5c9 100644 --- a/solana/pyth2wormhole/program/src/lib.rs +++ b/solana/pyth2wormhole/program/src/lib.rs @@ -5,6 +5,7 @@ pub mod initialize; pub mod message; pub mod migrate; pub mod set_config; +pub mod set_is_active; use solitaire::solitaire; @@ -27,6 +28,11 @@ pub use set_config::{ SetConfig, }; +pub use set_is_active::{ + set_is_active, + SetIsActive, +}; + pub use pyth_client; solitaire! { @@ -34,4 +40,5 @@ solitaire! { Initialize => initialize, SetConfig => set_config, Migrate => migrate, + SetIsActive => set_is_active } diff --git a/solana/pyth2wormhole/program/src/set_is_active.rs b/solana/pyth2wormhole/program/src/set_is_active.rs new file mode 100644 index 0000000000..e6e9e875ca --- /dev/null +++ b/solana/pyth2wormhole/program/src/set_is_active.rs @@ -0,0 +1,55 @@ +use solitaire::{ + trace, + AccountState, + ExecutionContext, + FromAccounts, + Info, + Keyed, + Mut, + Peel, + Result as SoliResult, + Signer, + SolitaireError, +}; + +use crate::config::{ + P2WConfigAccount, + Pyth2WormholeConfig, +}; + +#[derive(FromAccounts)] +pub struct SetIsActive<'b> { + /// Current config used by the program + pub config: Mut>, + /// Current owner authority of the program + pub ops_owner: Mut>>, + /// Payer account for updating the account data + pub payer: Mut>>, +} + +/// Alters the current settings of pyth2wormhole +pub fn set_is_active( + _ctx: &ExecutionContext, + accs: &mut SetIsActive, + new_is_active: bool, +) -> SoliResult<()> { + let cfg_struct: &mut Pyth2WormholeConfig = &mut accs.config; // unpack Data via nested Deref impls + match &cfg_struct.ops_owner { + None => Err(SolitaireError::InvalidOwner(*accs.ops_owner.info().key)), + Some(current_ops_owner) => { + if current_ops_owner != accs.ops_owner.info().key { + trace!( + "Ops owner account mismatch (expected {:?})", + current_ops_owner + ); + return Err(SolitaireError::InvalidOwner( + *accs.ops_owner.info().key, + )); + } + + cfg_struct.is_active = new_is_active; + + Ok(()) + } + } +}