Jelajahi Sumber

pyth2wormhole: Add an is_active flag to on-chain config (#224)

* pyth2wormhole: Add an is_active flag to on-chain config

This new flag will help enable/disable attester contracts between deployments

* p2w-client: typo

* p2w-client: add is_active to init

* pyth2wormhole config.rs: mention removing old structs, reword

* pyth2wormhole: remove unused config data struct from migrate()

* pyth2wormhole migrate(): leave a note about AccountState::Uninitialized

* Update solana/pyth2wormhole/program/src/migrate.rs

Co-authored-by: Reisen <Reisen@users.noreply.github.com>

* Update solana/pyth2wormhole/program/src/migrate.rs

Co-authored-by: Reisen <Reisen@users.noreply.github.com>

* Update solana/pyth2wormhole/program/src/migrate.rs

Co-authored-by: Reisen <Reisen@users.noreply.github.com>

* p2w program: missing deref typo

Co-authored-by: Reisen <Reisen@users.noreply.github.com>
Stanisław Drozd 3 tahun lalu
induk
melakukan
bbad8fb544

+ 18 - 6
solana/pyth2wormhole/client/src/cli.rs

@@ -3,7 +3,10 @@
 use solana_program::pubkey::Pubkey;
 use std::path::PathBuf;
 
-use clap::{Parser, Subcommand};
+use clap::{
+    Parser,
+    Subcommand,
+};
 
 #[derive(Parser)]
 #[clap(
@@ -43,6 +46,9 @@ pub enum Action {
         owner_addr: Pubkey,
         #[clap(short = 'p', long = "pyth-owner")]
         pyth_owner_addr: Pubkey,
+        /// Option<> makes sure not specifying this flag does not imply "false"
+        #[clap(long = "is-active")]
+        is_active: Option<bool>,
     },
     #[clap(
         about = "Use an existing pyth2wormhole program to attest product price information to another chain"
@@ -61,7 +67,7 @@ pub enum Action {
         #[clap(
             short = 'd',
             long = "--daemon",
-            help = "Do not stop attesting. In this mode, this program will behave more like a daemon and continuously attest the specified symbols.",
+            help = "Do not stop attesting. In this mode, this program will behave more like a daemon and continuously attest the specified symbols."
         )]
         daemon: bool,
         #[clap(
@@ -84,14 +90,20 @@ pub enum Action {
     #[clap(about = "Update an existing pyth2wormhole program's settings")]
     SetConfig {
         /// Current owner keypair path
-        #[clap(long = "owner", default_value = "~/.config/solana/id.json")]
+        #[clap(
+            long,
+            default_value = "~/.config/solana/id.json",
+            help = "Keypair file for the current config owner"
+        )]
         owner: String,
         /// New owner to set
         #[clap(long = "new-owner")]
-        new_owner_addr: Pubkey,
+        new_owner_addr: Option<Pubkey>,
         #[clap(long = "new-wh-prog")]
-        new_wh_prog: Pubkey,
+        new_wh_prog: Option<Pubkey>,
         #[clap(long = "new-pyth-owner")]
-        new_pyth_owner_addr: Pubkey,
+        new_pyth_owner_addr: Option<Pubkey>,
+        #[clap(long = "is-active")]
+        is_active: Option<bool>,
     },
 }

+ 15 - 21
solana/pyth2wormhole/client/src/lib.rs

@@ -53,15 +53,20 @@ use pyth2wormhole::{
     Pyth2WormholeConfig,
 };
 
-pub use attestation_cfg::{AttestationConfig, AttestationConditions, P2WSymbol};
-pub use batch_state::{BatchState, BatchTxStatus};
+pub use attestation_cfg::{
+    AttestationConditions,
+    AttestationConfig,
+    P2WSymbol,
+};
+pub use batch_state::{
+    BatchState,
+    BatchTxStatus,
+};
 
 pub fn gen_init_tx(
     payer: Keypair,
     p2w_addr: Pubkey,
-    new_owner_addr: Pubkey,
-    wh_prog: Pubkey,
-    pyth_owner_addr: Pubkey,
+    config: Pyth2WormholeConfig,
     latest_blockhash: Hash,
 ) -> Result<Transaction, ErrBox> {
     use AccEntry::*;
@@ -73,12 +78,6 @@ pub fn gen_init_tx(
         new_config: Derived(p2w_addr),
     };
 
-    let config = Pyth2WormholeConfig {
-        max_batch_size: P2W_MAX_BATCH_SIZE,
-        owner: new_owner_addr,
-        wh_prog: wh_prog,
-        pyth_owner: pyth_owner_addr,
-    };
     let ix_data = (pyth2wormhole::instruction::Instruction::Initialize, config);
 
     let (ix, signers) = accs.to_ix(p2w_addr, ix_data.try_to_vec()?.as_slice())?;
@@ -96,9 +95,7 @@ pub fn gen_set_config_tx(
     payer: Keypair,
     p2w_addr: Pubkey,
     owner: Keypair,
-    new_owner_addr: Pubkey,
-    new_wh_prog: Pubkey,
-    new_pyth_owner_addr: Pubkey,
+    new_config: Pyth2WormholeConfig,
     latest_blockhash: Hash,
 ) -> Result<Transaction, ErrBox> {
     use AccEntry::*;
@@ -111,13 +108,10 @@ pub fn gen_set_config_tx(
         config: Derived(p2w_addr),
     };
 
-    let config = Pyth2WormholeConfig {
-        max_batch_size: P2W_MAX_BATCH_SIZE,
-        owner: new_owner_addr,
-        wh_prog: new_wh_prog,
-        pyth_owner: new_pyth_owner_addr,
-    };
-    let ix_data = (pyth2wormhole::instruction::Instruction::SetConfig, config);
+    let ix_data = (
+        pyth2wormhole::instruction::Instruction::SetConfig,
+        new_config,
+    );
 
     let (ix, signers) = accs.to_ix(p2w_addr, ix_data.try_to_vec()?.as_slice())?;
 

+ 25 - 6
solana/pyth2wormhole/client/src/main.rs

@@ -41,6 +41,10 @@ use cli::{
 
 use p2w_sdk::P2WEmitter;
 
+use pyth2wormhole::{
+    attest::P2W_MAX_BATCH_SIZE,
+    Pyth2WormholeConfig,
+};
 use pyth2wormhole_client::*;
 
 pub const SEQNO_PREFIX: &'static str = "Program log: Sequence: ";
@@ -61,13 +65,18 @@ fn main() -> Result<(), ErrBox> {
             owner_addr,
             pyth_owner_addr,
             wh_prog,
+            is_active,
         } => {
             let tx = gen_init_tx(
                 payer,
                 p2w_addr,
-                owner_addr,
-                wh_prog,
-                pyth_owner_addr,
+                Pyth2WormholeConfig {
+                    owner: owner_addr,
+                    wh_prog,
+                    pyth_owner: pyth_owner_addr,
+                    is_active: is_active.unwrap_or(true),
+                    max_batch_size: P2W_MAX_BATCH_SIZE,
+                },
                 latest_blockhash,
             )?;
             rpc_client.send_and_confirm_transaction_with_spinner(&tx)?;
@@ -80,17 +89,27 @@ fn main() -> Result<(), ErrBox> {
             new_owner_addr,
             new_wh_prog,
             new_pyth_owner_addr,
+            is_active,
         } => {
+            let old_config = get_config_account(&rpc_client, &p2w_addr)?;
             let tx = gen_set_config_tx(
                 payer,
                 p2w_addr,
                 read_keypair_file(&*shellexpand::tilde(&owner))?,
-                new_owner_addr,
-                new_wh_prog,
-                new_pyth_owner_addr,
+                Pyth2WormholeConfig {
+                    owner: new_owner_addr.unwrap_or(old_config.owner),
+                    wh_prog: new_wh_prog.unwrap_or(old_config.wh_prog),
+                    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,
+                },
                 latest_blockhash,
             )?;
             rpc_client.send_and_confirm_transaction_with_spinner(&tx)?;
+            println!(
+                "Applied conifg:\n{:?}",
+                get_config_account(&rpc_client, &p2w_addr)?
+            );
         }
         Action::Attest {
             ref attestation_cfg,

+ 1 - 0
solana/pyth2wormhole/client/tests/test_attest.rs

@@ -52,6 +52,7 @@ async fn test_happy_path() -> Result<(), solitaire::ErrBox> {
         wh_prog: wh_fixture_program_id,
         max_batch_size: pyth2wormhole::attest::P2W_MAX_BATCH_SIZE,
         pyth_owner,
+        is_active: true,
     };
 
     let bridge_config = BridgeData {

+ 7 - 0
solana/pyth2wormhole/program/src/attest.rs

@@ -140,6 +140,13 @@ impl<'b> InstructionContext<'b> for Attest<'b> {
 }
 
 pub fn attest(ctx: &ExecutionContext, accs: &mut Attest, data: AttestData) -> SoliResult<()> {
+    if !accs.config.is_active {
+        // msg instead of trace makes sure we're not silent about this in prod
+        solana_program::msg!("This attester program is disabled!");
+
+        return Err(SolitaireError::Custom(4242));
+    }
+
     accs.config.verify_derivation(ctx.program_id, None)?;
 
     if accs.config.wh_prog != *accs.wh_prog.key {

+ 81 - 12
solana/pyth2wormhole/program/src/config.rs

@@ -1,9 +1,24 @@
 //! On-chain state for the pyth2wormhole SOL contract.
 //!
-//! Important: A config init/update should be performed on every
-//! deployment/upgrade of this Solana program. Doing so prevents
-//! problems related to max batch size mismatches between config and
-//! contract logic. See attest.rs for details.
+//! Important: Changes to max batch size must be reflected in the
+//! instruction logic in attest.rs (look there for more
+//! details). Mismatches between config and contract logic may confuse
+//! attesters.
+//!
+//! How to add a new config schema:
+//! X - new config version number
+//! Y = X - 1; previous config number
+//! 1. Add a next Pyth2WormholeConfigVX struct,
+//! e.g. Pyth2WormholeConfigV3,
+//! 2. Add a P2WConfigAccountVX type alias with a unique seed str
+//! 3. Implement From<Pyth2WormholeConfigVY> for the new struct,
+//! e.g. From<Pyth2WormholeConfigV2> for Pyth2WormholeConfigV3
+//! 4. Advance Pyth2WormholeConfig, P2WConfigAccount,
+//! OldPyth2WormholeConfig, OldP2WConfigAccount typedefs to use the
+//! previous and new config structs.
+//! 5. Deploy and call migrate() to verify
+//! 6. (optional) Remove/comment out config structs and aliases from
+//! before version Y.
 
 use borsh::{
     BorshDeserialize,
@@ -18,9 +33,47 @@ use solitaire::{
     Owned,
 };
 
-#[derive(Default, BorshDeserialize, BorshSerialize)]
+/// Aliases for current config schema (to migrate into)
+pub type Pyth2WormholeConfig = Pyth2WormholeConfigV2;
+pub type P2WConfigAccount<'b, const IsInitialized: AccountState> =
+    P2WConfigAccountV2<'b, IsInitialized>;
+
+impl Owned for Pyth2WormholeConfig {
+    fn owner(&self) -> AccountOwner {
+        AccountOwner::This
+    }
+}
+
+/// 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
+
+impl Owned for OldPyth2WormholeConfig {
+    fn owner(&self) -> AccountOwner {
+        AccountOwner::This
+    }
+}
+
+/// Initial config format
+#[derive(Clone, Default, BorshDeserialize, BorshSerialize)]
+#[cfg_attr(feature = "client", derive(Debug))]
+pub struct Pyth2WormholeConfigV1 {
+    ///  Authority owning this contract
+    pub owner: Pubkey,
+    /// Wormhole bridge program
+    pub wh_prog: Pubkey,
+    /// Authority owning Pyth price data
+    pub pyth_owner: Pubkey,
+    pub max_batch_size: u16,
+}
+
+pub type P2WConfigAccountV1<'b, const IsInitialized: AccountState> =
+    Derive<Data<'b, Pyth2WormholeConfigV1, { IsInitialized }>, "pyth2wormhole-config">;
+
+/// Added is_active
+#[derive(Clone, Default, BorshDeserialize, BorshSerialize)]
 #[cfg_attr(feature = "client", derive(Debug))]
-pub struct Pyth2WormholeConfig {
+pub struct Pyth2WormholeConfigV2 {
     ///  Authority owning this contract
     pub owner: Pubkey,
     /// Wormhole bridge program
@@ -33,13 +86,29 @@ pub struct Pyth2WormholeConfig {
     /// 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,
 }
 
-impl Owned for Pyth2WormholeConfig {
-    fn owner(&self) -> AccountOwner {
-        AccountOwner::This
+pub type P2WConfigAccountV2<'b, const IsInitialized: AccountState> =
+    Derive<Data<'b, Pyth2WormholeConfigV2, { IsInitialized }>, "pyth2wormhole-config-v2">;
+
+impl From<Pyth2WormholeConfigV1> for Pyth2WormholeConfigV2 {
+    fn from(old: Pyth2WormholeConfigV1) -> Self {
+        let Pyth2WormholeConfigV1 {
+            owner,
+            wh_prog,
+            pyth_owner,
+            max_batch_size,
+        } = old;
+
+        Self {
+            owner,
+            wh_prog,
+            pyth_owner,
+            max_batch_size,
+            is_active: true,
+        }
     }
 }
-
-pub type P2WConfigAccount<'b, const IsInitialized: AccountState> =
-    Derive<Data<'b, Pyth2WormholeConfig, { IsInitialized }>, "pyth2wormhole-config">;

+ 6 - 0
solana/pyth2wormhole/program/src/lib.rs

@@ -2,6 +2,7 @@
 pub mod attest;
 pub mod config;
 pub mod initialize;
+pub mod migrate;
 pub mod set_config;
 
 use solitaire::solitaire;
@@ -16,6 +17,10 @@ pub use initialize::{
     initialize,
     Initialize,
 };
+pub use migrate::{
+    migrate,
+    Migrate,
+};
 pub use set_config::{
     set_config,
     SetConfig,
@@ -27,4 +32,5 @@ solitaire! {
     Attest(AttestData) => attest,
     Initialize(Pyth2WormholeConfig) => initialize,
     SetConfig(Pyth2WormholeConfig) => set_config,
+    Migrate(()) => migrate,
 }

+ 95 - 0
solana/pyth2wormhole/program/src/migrate.rs

@@ -0,0 +1,95 @@
+//! Instruction used to migrate on-chain configuration from an older format
+
+use solana_program::{
+    program_error::ProgramError,
+    pubkey::Pubkey,
+};
+
+use solitaire::{
+    trace,
+    AccountState,
+    CreationLamports,
+    ExecutionContext,
+    FromAccounts,
+    Info,
+    InstructionContext,
+    Keyed,
+    Mut,
+    Peel,
+    Result as SoliResult,
+    Signer,
+    SolitaireError,
+    ToInstruction,
+};
+
+use crate::config::{
+    OldP2WConfigAccount,
+    OldPyth2WormholeConfig,
+    P2WConfigAccount,
+    Pyth2WormholeConfig,
+};
+
+/// Migration accounts meant to evolve with subsequent config accounts
+///
+/// NOTE: This account struct assumes Solitaire is able to validate the
+/// Uninitialized requirement on the new_config account
+#[derive(FromAccounts, ToInstruction)]
+pub struct Migrate<'b> {
+    /// New config account to be populated. Must be unused.
+    pub new_config: Mut<P2WConfigAccount<'b, { AccountState::Uninitialized }>>,
+    /// Old config using the previous format.
+    pub old_config: OldP2WConfigAccount<'b>,
+    /// Current owner authority of the program
+    pub current_owner: Mut<Signer<Info<'b>>>,
+    /// Payer account for updating the account data
+    pub payer: Mut<Signer<Info<'b>>>,
+}
+
+impl<'b> InstructionContext<'b> for Migrate<'b> {
+    fn deps(&self) -> Vec<Pubkey> {
+        vec![]
+    }
+}
+
+pub fn migrate(
+    ctx: &ExecutionContext,
+    accs: &mut Migrate,
+    data: (),
+) -> SoliResult<()> {
+    let old_config: &OldPyth2WormholeConfig = &accs.old_config.1;
+
+    if &old_config.owner != accs.current_owner.info().key {
+        trace!(
+            "Current config owner account mismatch (expected {:?})",
+            old_config.owner
+        );
+        return Err(SolitaireError::InvalidSigner(
+            accs.current_owner.info().key.clone(),
+        ));
+    }
+
+    // Populate new config
+    accs.new_config
+        .create(ctx, accs.payer.info().key, CreationLamports::Exempt)?;
+    accs.new_config.1 = Pyth2WormholeConfig::from(old_config.clone());
+
+    // Reclaim old config lamports
+
+    // Save current balance
+    let old_config_balance_val: u64 = accs.old_config.info().lamports();
+
+    // Drain old config
+    **accs.old_config.info().lamports.borrow_mut() = 0;
+
+    // Credit payer with saved balance
+    accs.payer.info()
+        .lamports
+        .borrow_mut()
+        .checked_add(old_config_balance_val)
+        .ok_or_else(|| {
+            trace!("Overflow on payer balance increase");
+            SolitaireError::ProgramError(ProgramError::Custom(0xDEADBEEF))
+        })?;
+
+    Ok(())
+}

+ 3 - 5
solana/pyth2wormhole/program/src/set_config.rs

@@ -1,8 +1,6 @@
-use solana_program::{
-    msg,
-    pubkey::Pubkey,
-};
+use solana_program::pubkey::Pubkey;
 use solitaire::{
+    trace,
     AccountState,
     ExecutionContext,
     FromAccounts,
@@ -46,7 +44,7 @@ pub fn set_config(
 ) -> SoliResult<()> {
     let cfgStruct: &Pyth2WormholeConfig = &accs.config; // unpack Data via nested Deref impls
     if &cfgStruct.owner != accs.current_owner.info().key {
-        msg!(
+        trace!(
             "Current owner account mismatch (expected {:?})",
             cfgStruct.owner
         );