diff --git a/target_chains/solana/Cargo.lock b/target_chains/solana/Cargo.lock index d064fb8b..e0b742a5 100644 --- a/target_chains/solana/Cargo.lock +++ b/target_chains/solana/Cargo.lock @@ -3027,6 +3027,7 @@ dependencies = [ "solana-sdk", "tokio", "wormhole-core-bridge-solana", + "wormhole-raw-vaas", "wormhole-sdk", ] diff --git a/target_chains/solana/cli/src/cli.rs b/target_chains/solana/cli/src/cli.rs index 2b2230f3..a5c413e4 100644 --- a/target_chains/solana/cli/src/cli.rs +++ b/target_chains/solana/cli/src/cli.rs @@ -40,6 +40,17 @@ pub enum Action { #[clap(short = 'p', long, help = "Payload from Hermes")] payload: String, }, + #[clap(about = "Post a price update from Hermes to Solana in one transaction")] + PostPriceUpdateAtomic { + #[clap(short = 'p', long, help = "Payload from Hermes")] + payload: String, + #[clap( + short = 'n', + default_value = "5", + help = "Number of signatures to verify. If n >= 5 this will fail because of the transaction size limit." + )] + n_signatures: usize, + }, #[clap( about = "Initialize a wormhole receiver contract by sequentially replaying the guardian set updates" )] diff --git a/target_chains/solana/cli/src/main.rs b/target_chains/solana/cli/src/main.rs index 9c78170e..ed923709 100644 --- a/target_chains/solana/cli/src/main.rs +++ b/target_chains/solana/cli/src/main.rs @@ -1,7 +1,8 @@ #![deny(warnings)] - pub mod cli; + + use { anchor_client::anchor_lang::{ InstructionData, @@ -14,7 +15,10 @@ use { Action, Cli, }, - pyth_solana_receiver::state::config::DataSource, + pyth_solana_receiver::{ + state::config::DataSource, + PostUpdatesAtomicParams, + }, pythnet_sdk::wire::v1::{ AccumulatorUpdateData, MerklePriceUpdate, @@ -102,6 +106,25 @@ fn main() -> Result<()> { &merkle_price_updates[0], )?; } + Action::PostPriceUpdateAtomic { + payload, + n_signatures, + } => { + let rpc_client = RpcClient::new(url); + let payer = + read_keypair_file(&*shellexpand::tilde(&keypair)).expect("Keypair not found"); + + let (vaa, merkle_price_updates) = deserialize_accumulator_update_data(&payload)?; + + process_post_price_update_atomic( + &rpc_client, + &vaa, + n_signatures, + &wormhole, + &payer, + &merkle_price_updates[0], + )?; + } Action::InitializeWormholeReceiver {} => { let rpc_client = RpcClient::new(url); @@ -140,6 +163,7 @@ fn main() -> Result<()> { false, )?; } + Action::InitializePythReceiver { fee, emitter, @@ -221,6 +245,55 @@ pub fn process_upgrade_guardian_set( Ok(()) } +pub fn process_post_price_update_atomic( + rpc_client: &RpcClient, + vaa: &[u8], + n_signatures: usize, + wormhole: &Pubkey, + payer: &Keypair, + merkle_price_update: &MerklePriceUpdate, +) -> Result { + let price_update_keypair = Keypair::new(); + + let (mut header, body): (Header, Body<&RawMessage>) = serde_wormhole::from_slice(vaa).unwrap(); + trim_signatures(&mut header, n_signatures); + + let request_compute_units_instruction: Instruction = + ComputeBudgetInstruction::set_compute_unit_limit(400_000); + + + let post_update_accounts = pyth_solana_receiver::accounts::PostUpdatesAtomic::populate( + payer.pubkey(), + price_update_keypair.pubkey(), + *wormhole, + header.guardian_set_index, + ) + .to_account_metas(None); + + let post_update_instruction = Instruction { + program_id: pyth_solana_receiver::id(), + accounts: post_update_accounts, + data: pyth_solana_receiver::instruction::PostUpdatesAtomic { + params: PostUpdatesAtomicParams { + merkle_price_update: merkle_price_update.clone(), + vaa: serde_wormhole::to_vec(&(header, body)).unwrap(), + }, + } + .data(), + }; + + process_transaction( + rpc_client, + vec![request_compute_units_instruction, post_update_instruction], + &vec![payer, &price_update_keypair], + )?; + Ok(price_update_keypair.pubkey()) +} + +fn trim_signatures(header: &mut Header, n_signatures: usize) { + header.signatures = header.signatures[..(n_signatures)].to_vec(); +} + fn deserialize_guardian_set(buf: &mut &[u8], legacy_guardian_set: bool) -> Result { if !legacy_guardian_set { // Skip anchor discriminator diff --git a/target_chains/solana/programs/pyth-solana-receiver/Cargo.toml b/target_chains/solana/programs/pyth-solana-receiver/Cargo.toml index 50cd05e2..e72af460 100644 --- a/target_chains/solana/programs/pyth-solana-receiver/Cargo.toml +++ b/target_chains/solana/programs/pyth-solana-receiver/Cargo.toml @@ -21,6 +21,7 @@ pythnet-sdk = { path = "../../../../pythnet/pythnet_sdk", version = "2.0.0" } solana-program = "1.16.20" byteorder = "1.4.3" wormhole-core-bridge-solana = {git = "https://github.com/guibescos/wormhole", branch = "variable-sigs"} +wormhole-raw-vaas = {version = "0.0.1-alpha.1", features = ["ruint", "on-chain"], default-features = false } wormhole-sdk = { git = "https://github.com/wormhole-foundation/wormhole", tag = "v2.17.1" } serde_wormhole = { git = "https://github.com/wormhole-foundation/wormhole", tag = "v2.17.1"} diff --git a/target_chains/solana/programs/pyth-solana-receiver/src/error.rs b/target_chains/solana/programs/pyth-solana-receiver/src/error.rs index 5f847a2f..5182844d 100644 --- a/target_chains/solana/programs/pyth-solana-receiver/src/error.rs +++ b/target_chains/solana/programs/pyth-solana-receiver/src/error.rs @@ -28,4 +28,15 @@ pub enum ReceiverError { NonexistentGovernanceAuthorityTransferRequest, #[msg("Funds are insufficient to pay the receiving fee")] InsufficientFunds, + // Wormhole errors + #[msg("Invalid VAA version")] + InvalidVaaVersion, + #[msg("Guardian set version in the VAA doesn't match the guardian set passed")] + GuardianSetMismatch, + #[msg("Guardian index exceeds the number of guardians in the set")] + InvalidGuardianIndex, + #[msg("A VAA signature is invalid")] + InvalidSignature, + #[msg("The recovered guardian public key doesn't match the guardian set")] + InvalidGuardianKeyRecovery, } diff --git a/target_chains/solana/programs/pyth-solana-receiver/src/lib.rs b/target_chains/solana/programs/pyth-solana-receiver/src/lib.rs index 91a5ce49..66695fb9 100644 --- a/target_chains/solana/programs/pyth-solana-receiver/src/lib.rs +++ b/target_chains/solana/programs/pyth-solana-receiver/src/lib.rs @@ -18,7 +18,12 @@ use { }, }, serde_wormhole::RawMessage, - solana_program::system_instruction, + solana_program::{ + keccak, + program_memory::sol_memcpy, + secp256k1_recover::secp256k1_recover, + system_instruction, + }, state::{ config::{ Config, @@ -26,7 +31,17 @@ use { }, price_update::PriceUpdateV1, }, - wormhole_core_bridge_solana::state::EncodedVaa, + wormhole_core_bridge_solana::{ + sdk::legacy::AccountVariant, + state::{ + EncodedVaa, + GuardianSet, + }, + }, + wormhole_raw_vaas::{ + GuardianSetSig, + Vaa, + }, wormhole_sdk::vaa::{ Body, Header, @@ -36,7 +51,6 @@ use { pub mod error; pub mod state; - declare_id!("rec5EKMGg6MxZYaMdyBfgwp4d5rB9T1VQH5pJv5LtFJ"); #[program] @@ -90,80 +104,115 @@ pub mod pyth_solana_receiver { Ok(()) } - /// Post a price update using an encoded_vaa account and a MerklePriceUpdate calldata. - /// This should be called after the client has already verified the Vaa via the Wormhole contract. - /// Check out target_chains/solana/cli/src/main.rs for an example of how to do this. - #[allow(unused_variables)] - pub fn post_updates(ctx: Context, price_update: MerklePriceUpdate) -> Result<()> { + + /// Post a price update using a VAA and a MerklePriceUpdate. + /// This function allows you to post a price update in a single transaction. + /// Compared to post_updates, it is less secure since you won't be able to verify all guardian signatures if you use this function because of transaction size limitations. + /// Typically, you can fit 5 guardian signatures in a transaction that uses this. + pub fn post_updates_atomic( + ctx: Context, + params: PostUpdatesAtomicParams, + ) -> Result<()> { + // This section is borrowed from https://github.com/wormhole-foundation/wormhole/blob/wen/solana-rewrite/solana/programs/core-bridge/src/processor/parse_and_verify_vaa/verify_encoded_vaa_v1.rs#L59 + let vaa = Vaa::parse(¶ms.vaa).map_err(|_| ReceiverError::DeserializeVaaFailed)?; + // Must be V1. + require_eq!(vaa.version(), 1, ReceiverError::InvalidVaaVersion); + + // Make sure the encoded guardian set index agrees with the guardian set account's index. + let guardian_set = ctx.accounts.guardian_set.inner(); + require_eq!( + vaa.guardian_set_index(), + guardian_set.index, + ReceiverError::GuardianSetMismatch + ); + + // Do we have enough signatures for quorum? + let guardian_keys = &guardian_set.keys; + + // Generate the same message hash (using keccak) that the Guardians used to generate their + // signatures. This message hash will be hashed again to produce the digest for + // `secp256k1_recover`. + let digest = keccak::hash(keccak::hash(vaa.body().as_ref()).as_ref()); + + let mut last_guardian_index = None; + for sig in vaa.signatures() { + // We do not allow for non-increasing guardian signature indices. + let index = usize::from(sig.guardian_index()); + if let Some(last_index) = last_guardian_index { + require!(index > last_index, ReceiverError::InvalidGuardianIndex); + } + + // Does this guardian index exist in this guardian set? + let guardian_pubkey = guardian_keys + .get(index) + .ok_or_else(|| error!(ReceiverError::InvalidGuardianIndex))?; + + // Now verify that the signature agrees with the expected Guardian's pubkey. + verify_guardian_signature(&sig, guardian_pubkey, digest.as_ref())?; + + last_guardian_index = Some(index); + } + // End borrowed section + let config = &ctx.accounts.config; let payer = &ctx.accounts.payer; - let encoded_vaa = &ctx.accounts.encoded_vaa; let treasury = &ctx.accounts.treasury; let price_update_account = &mut ctx.accounts.price_update_account; - if payer.lamports() - < Rent::get()? - .minimum_balance(0) - .saturating_add(config.single_update_fee_in_lamports) - { - return err!(ReceiverError::InsufficientFunds); + let vaa_components = VaaComponents { + verified_signatures: vaa.signature_count(), + emitter_address: vaa.body().emitter_address(), + emitter_chain: vaa.body().emitter_chain(), }; - let transfer_instruction = system_instruction::transfer( - payer.key, - treasury.key, - config.single_update_fee_in_lamports, - ); - anchor_lang::solana_program::program::invoke( - &transfer_instruction, - &[ - ctx.accounts.payer.to_account_info(), - ctx.accounts.treasury.to_account_info(), - ], + post_price_update_from_vaa( + config, + payer, + treasury, + price_update_account, + &vaa_components, + vaa.payload().as_ref(), + ¶ms.merkle_price_update, )?; - let (header, body): (Header, Body<&RawMessage>) = - serde_wormhole::from_slice(&ctx.accounts.encoded_vaa.buf).unwrap(); - let valid_data_source = config.valid_data_sources.iter().any(|x| { - *x == DataSource { - chain: body.emitter_chain.into(), - emitter: Pubkey::from(body.emitter_address.0), - } - }); - if !valid_data_source { - return err!(ReceiverError::InvalidDataSource); - } + Ok(()) + } - let wormhole_message = WormholeMessage::try_from_bytes(body.payload) - .map_err(|_| ReceiverError::InvalidWormholeMessage)?; - let root: MerkleRoot = MerkleRoot::new(match wormhole_message.payload { - WormholePayload::Merkle(merkle_root) => merkle_root.root, - }); + /// Post a price update using an encoded_vaa account and a MerklePriceUpdate calldata. + /// This should be called after the client has already verified the Vaa via the Wormhole contract. + /// Check out target_chains/solana/cli/src/main.rs for an example of how to do this. + pub fn post_updates(ctx: Context, price_update: MerklePriceUpdate) -> Result<()> { + let config = &ctx.accounts.config; + let payer: &Signer<'_> = &ctx.accounts.payer; + let encoded_vaa = &ctx.accounts.encoded_vaa; + let treasury: &AccountInfo<'_> = &ctx.accounts.treasury; + let price_update_account: &mut Account<'_, PriceUpdateV1> = + &mut ctx.accounts.price_update_account; - if !root.check(price_update.proof, price_update.message.as_ref()) { - return err!(ReceiverError::InvalidPriceUpdate); - } + let (_, body): (Header, Body<&RawMessage>) = + serde_wormhole::from_slice(&encoded_vaa.buf).unwrap(); - let message = from_slice::(price_update.message.as_ref()) - .map_err(|_| ReceiverError::DeserializeMessageFailed)?; + let vaa_components = VaaComponents { + verified_signatures: encoded_vaa.header.verified_signatures, + emitter_address: body.emitter_address.0, + emitter_chain: body.emitter_chain.into(), + }; - match message { - Message::PriceFeedMessage(price_feed_message) => { - price_update_account.write_authority = payer.key(); - price_update_account.verified_signatures = encoded_vaa.header.verified_signatures; - price_update_account.price_message = price_feed_message; - } - Message::TwapMessage(twap_message) => { - return err!(ReceiverError::UnsupportedMessageType); - } - } + post_price_update_from_vaa( + config, + payer, + treasury, + price_update_account, + &vaa_components, + body.payload, + &price_update, + )?; Ok(()) } } - pub const CONFIG_SEED: &str = "config"; pub const TREASURY_SEED: &str = "treasury"; @@ -199,7 +248,6 @@ pub struct AuthorizeGovernanceAuthorityTransfer<'info> { pub config: Account<'info, Config>, } - #[derive(Accounts)] pub struct PostUpdates<'info> { #[account(mut)] @@ -217,6 +265,35 @@ pub struct PostUpdates<'info> { pub system_program: Program<'info, System>, } +#[derive(Accounts)] +pub struct PostUpdatesAtomic<'info> { + #[account(mut)] + pub payer: Signer<'info>, + #[account( + seeds = [ + GuardianSet::SEED_PREFIX, + guardian_set.inner().index.to_be_bytes().as_ref() + ], + seeds::program = config.wormhole, + bump, + owner = config.wormhole)] + pub guardian_set: Account<'info, AccountVariant>, + #[account(seeds = [CONFIG_SEED.as_ref()], bump)] + pub config: Account<'info, Config>, + #[account(mut, seeds = [TREASURY_SEED.as_ref()], bump)] + /// CHECK: This is just a PDA controlled by the program. There is currently no way to withdraw funds from it. + pub treasury: AccountInfo<'info>, + #[account(init, payer = payer, space = PriceUpdateV1::LEN)] + pub price_update_account: Account<'info, PriceUpdateV1>, + pub system_program: Program<'info, System>, +} + +#[derive(Debug, AnchorSerialize, AnchorDeserialize, Clone)] +pub struct PostUpdatesAtomicParams { + pub vaa: Vec, + pub merkle_price_update: MerklePriceUpdate, +} + impl crate::accounts::Initialize { pub fn populate(payer: &Pubkey) -> Self { let config = Pubkey::find_program_address(&[CONFIG_SEED.as_ref()], &crate::ID).0; @@ -228,6 +305,36 @@ impl crate::accounts::Initialize { } } +impl crate::accounts::PostUpdatesAtomic { + pub fn populate( + payer: Pubkey, + price_update_account: Pubkey, + wormhole_address: Pubkey, + guardian_set_index: u32, + ) -> Self { + let config = Pubkey::find_program_address(&[CONFIG_SEED.as_ref()], &crate::ID).0; + let treasury = Pubkey::find_program_address(&[TREASURY_SEED.as_ref()], &crate::ID).0; + + let guardian_set = Pubkey::find_program_address( + &[ + GuardianSet::SEED_PREFIX, + guardian_set_index.to_be_bytes().as_ref(), + ], + &wormhole_address, + ) + .0; + + crate::accounts::PostUpdatesAtomic { + payer, + guardian_set, + config, + treasury, + price_update_account, + system_program: system_program::ID, + } + } +} + impl crate::accounts::PostUpdates { pub fn populate(payer: Pubkey, encoded_vaa: Pubkey, price_update_account: Pubkey) -> Self { let config = Pubkey::find_program_address(&[CONFIG_SEED.as_ref()], &crate::ID).0; @@ -243,3 +350,106 @@ impl crate::accounts::PostUpdates { } } } + +struct VaaComponents { + verified_signatures: u8, + emitter_address: [u8; 32], + emitter_chain: u16, +} + +fn post_price_update_from_vaa<'info>( + config: &Account<'info, Config>, + payer: &Signer<'info>, + treasury: &AccountInfo<'info>, + price_update_account: &mut Account<'_, PriceUpdateV1>, + vaa_components: &VaaComponents, + vaa_payload: &[u8], + price_update: &MerklePriceUpdate, +) -> Result<()> { + if payer.lamports() + < Rent::get()? + .minimum_balance(0) + .saturating_add(config.single_update_fee_in_lamports) + { + return err!(ReceiverError::InsufficientFunds); + }; + + let transfer_instruction = system_instruction::transfer( + payer.key, + treasury.key, + config.single_update_fee_in_lamports, + ); + anchor_lang::solana_program::program::invoke( + &transfer_instruction, + &[payer.to_account_info(), treasury.to_account_info()], + )?; + + let valid_data_source = config.valid_data_sources.iter().any(|x| { + *x == DataSource { + chain: vaa_components.emitter_chain, + emitter: Pubkey::from(vaa_components.emitter_address), + } + }); + if !valid_data_source { + return err!(ReceiverError::InvalidDataSource); + } + + let wormhole_message = WormholeMessage::try_from_bytes(vaa_payload) + .map_err(|_| ReceiverError::InvalidWormholeMessage)?; + let root: MerkleRoot = MerkleRoot::new(match wormhole_message.payload { + WormholePayload::Merkle(merkle_root) => merkle_root.root, + }); + + if !root.check(price_update.proof.clone(), price_update.message.as_ref()) { + return err!(ReceiverError::InvalidPriceUpdate); + } + + let message = from_slice::(price_update.message.as_ref()) + .map_err(|_| ReceiverError::DeserializeMessageFailed)?; + + match message { + Message::PriceFeedMessage(price_feed_message) => { + price_update_account.write_authority = payer.key(); + price_update_account.verified_signatures = vaa_components.verified_signatures; + price_update_account.price_message = price_feed_message; + } + Message::TwapMessage(_) => { + return err!(ReceiverError::UnsupportedMessageType); + } + } + Ok(()) +} + +/** + * Borrowed from https://github.com/wormhole-foundation/wormhole/blob/wen/solana-rewrite/solana/programs/core-bridge/src/processor/parse_and_verify_vaa/verify_encoded_vaa_v1.rs#L121 + */ +fn verify_guardian_signature( + sig: &GuardianSetSig, + guardian_pubkey: &[u8; 20], + digest: &[u8], +) -> Result<()> { + // Recover using `solana_program::secp256k1_recover`. Public key recovery costs 25k compute + // units. And hashing this public key to recover the Ethereum public key costs about 13k. + let recovered = { + // Recover EC public key (64 bytes). + let pubkey = secp256k1_recover(digest, sig.recovery_id(), &sig.rs()) + .map_err(|_| ReceiverError::InvalidSignature)?; + + // The Ethereum public key is the last 20 bytes of keccak hashed public key above. + let hashed = keccak::hash(&pubkey.to_bytes()); + + let mut eth_pubkey = [0; 20]; + sol_memcpy(&mut eth_pubkey, &hashed.0[12..], 20); + + eth_pubkey + }; + + // The recovered public key should agree with the Guardian's public key at this index. + require!( + recovered == *guardian_pubkey, + ReceiverError::InvalidGuardianKeyRecovery + ); + + // Done. + Ok(()) +}