Преглед изворни кода

Attestation: Add ops owner and set-is-active ix (#295)

* Attestation: Add ops owner and set-is-active ix

* Update solana/pyth2wormhole/client/src/cli.rs

Co-authored-by: Stanisław Drozd <stan@nexantic.com>

* Fix typos

* Add a test without owner

Co-authored-by: Stanisław Drozd <stan@nexantic.com>
Ali Behjati пре 3 година
родитељ
комит
77083cf760

+ 23 - 0
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<bool>,
+        #[clap(long = "ops-owner")]
+        ops_owner_addr: Option<Pubkey>,
     },
     #[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<Pubkey>,
         #[clap(long = "is-active")]
         is_active: Option<bool>,
+        #[clap(long = "ops-owner")]
+        ops_owner_addr: Option<Pubkey>,
+        #[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,
+    }
 }

+ 39 - 0
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<Transaction, ErrBox> {
+    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::<Vec<&Keypair>>(
+        &[ix],
+        Some(&payer_pubkey),
+        &signers,
+        latest_blockhash,
+    );
+    Ok(tx_signed)
+}
+
 pub fn gen_migrate_tx(
     payer: Keypair,
     p2w_addr: Pubkey,

+ 32 - 4
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(())

+ 2 - 0
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 {

+ 4 - 0
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()");

+ 177 - 0
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::<Pyth2WormholeConfig>(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::<Pyth2WormholeConfig>(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(())
+}

+ 53 - 4
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<Pyth2WormholeConfigV1> 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<Pubkey>,
+}
+
+pub type P2WConfigAccountV3<'b, const IsInitialized: AccountState> =
+    Derive<Data<'b, Pyth2WormholeConfigV3, { IsInitialized }>, "pyth2wormhole-config-v3">;
+
+impl From<Pyth2WormholeConfigV2> 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
+        }
+    }
+}

+ 7 - 0
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
 }

+ 55 - 0
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<P2WConfigAccount<'b, { AccountState::Initialized }>>,
+    /// Current owner authority of the program
+    pub ops_owner: Mut<Signer<Info<'b>>>,
+    /// Payer account for updating the account data
+    pub payer: Mut<Signer<Info<'b>>>,
+}
+
+/// 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(())
+        }
+    }
+}