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>
This commit is contained in:
Ali Behjati 2022-09-21 21:14:23 +02:00 committed by GitHub
parent bf032f07c2
commit 77083cf760
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 392 additions and 8 deletions

View File

@ -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,
}
}

View File

@ -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,

View File

@ -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(())

View File

@ -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 {

View File

@ -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()");

View File

@ -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(())
}

View File

@ -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
}
}
}

View File

@ -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
}

View File

@ -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(())
}
}
}