From d1d683eabd862a91ce995a362e6465cfc08b93e8 Mon Sep 17 00:00:00 2001 From: Trevor Porter Date: Thu, 27 Jul 2023 10:27:39 +0100 Subject: [PATCH 1/5] Sealevel IGP --- rust/Cargo.lock | 34 +- rust/Cargo.toml | 1 + rust/sealevel/.gitignore | 2 +- .../account-utils/src/discriminator.rs | 108 ++ .../libraries/account-utils/src/lib.rs | 88 +- .../interchain-gas-paymaster/Cargo.toml | 30 + .../interchain-gas-paymaster/src/accounts.rs | 298 ++++ .../interchain-gas-paymaster/src/error.rs | 18 + .../src/instruction.rs | 95 + .../interchain-gas-paymaster/src/lib.rs | 11 + .../interchain-gas-paymaster/src/pda_seeds.rs | 83 + .../interchain-gas-paymaster/src/processor.rs | 654 +++++++ .../tests/functional.rs | 1577 +++++++++++++++++ .../ism/multisig-ism-message-id/Cargo.toml | 2 +- .../tests/functional.rs | 20 +- .../programs/validator-announce/Cargo.toml | 1 + .../validator-announce/tests/functional.rs | 62 +- 17 files changed, 2971 insertions(+), 113 deletions(-) create mode 100644 rust/sealevel/libraries/account-utils/src/discriminator.rs create mode 100644 rust/sealevel/programs/interchain-gas-paymaster/Cargo.toml create mode 100644 rust/sealevel/programs/interchain-gas-paymaster/src/accounts.rs create mode 100644 rust/sealevel/programs/interchain-gas-paymaster/src/error.rs create mode 100644 rust/sealevel/programs/interchain-gas-paymaster/src/instruction.rs create mode 100644 rust/sealevel/programs/interchain-gas-paymaster/src/lib.rs create mode 100644 rust/sealevel/programs/interchain-gas-paymaster/src/pda_seeds.rs create mode 100644 rust/sealevel/programs/interchain-gas-paymaster/src/processor.rs create mode 100644 rust/sealevel/programs/interchain-gas-paymaster/tests/functional.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d24082f19a..862c5062a1 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -3793,6 +3793,25 @@ dependencies = [ "solana-program", ] +[[package]] +name = "hyperlane-sealevel-igp" +version = "0.1.0" +dependencies = [ + "access-control", + "account-utils", + "borsh 0.9.3", + "getrandom 0.2.10", + "hyperlane-core", + "hyperlane-test-utils", + "num-derive", + "num-traits", + "serializable-account-meta", + "solana-program", + "solana-program-test", + "solana-sdk", + "thiserror", +] + [[package]] name = "hyperlane-sealevel-interchain-security-module-interface" version = "0.1.0" @@ -3874,6 +3893,7 @@ dependencies = [ "hyperlane-sealevel-interchain-security-module-interface", "hyperlane-sealevel-mailbox", "hyperlane-sealevel-multisig-ism-message-id", + "hyperlane-test-utils", "multisig-ism", "num-derive", "num-traits", @@ -4024,6 +4044,7 @@ dependencies = [ "hex 0.4.3", "hyperlane-core", "hyperlane-sealevel-mailbox", + "hyperlane-test-utils", "serializable-account-meta", "solana-program", "solana-program-test", @@ -9193,13 +9214,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b11c96ac7ee530603dcdf68ed1557050f374ce55a5a07193ebf8cbc9f8927e9" dependencies = [ "base64 0.21.2", - "flate2", "log", "once_cell", - "rustls 0.21.2", - "rustls-webpki", "url", - "webpki-roots 0.23.1", ] [[package]] @@ -9505,15 +9522,6 @@ dependencies = [ "webpki 0.22.0", ] -[[package]] -name = "webpki-roots" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03058f88386e5ff5310d9111d53f48b17d732b401aeb83a8d5190f2ac459338" -dependencies = [ - "rustls-webpki", -] - [[package]] name = "which" version = "4.4.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 87d8cc60e5..9fd9118847 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -25,6 +25,7 @@ members = [ "sealevel/programs/hyperlane-sealevel-token", "sealevel/programs/hyperlane-sealevel-token-collateral", "sealevel/programs/hyperlane-sealevel-token-native", + "sealevel/programs/interchain-gas-paymaster", "sealevel/programs/ism/multisig-ism-message-id", "sealevel/programs/ism/test-ism", "sealevel/programs/mailbox", diff --git a/rust/sealevel/.gitignore b/rust/sealevel/.gitignore index b21c3b0132..1276e94d63 100644 --- a/rust/sealevel/.gitignore +++ b/rust/sealevel/.gitignore @@ -1,2 +1,2 @@ /target -environments/**/deploy-logs.txt \ No newline at end of file +environments/**/deploy-logs.txt diff --git a/rust/sealevel/libraries/account-utils/src/discriminator.rs b/rust/sealevel/libraries/account-utils/src/discriminator.rs new file mode 100644 index 0000000000..a9c9836383 --- /dev/null +++ b/rust/sealevel/libraries/account-utils/src/discriminator.rs @@ -0,0 +1,108 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_program::program_error::ProgramError; +use spl_type_length_value::discriminator::Discriminator; +use std::ops::Deref; + +use crate::SizedData; + +pub const PROGRAM_INSTRUCTION_DISCRIMINATOR: [u8; Discriminator::LENGTH] = [1, 1, 1, 1, 1, 1, 1, 1]; + +/// A wrapper type that prefixes data with a discriminator when Borsh (de)serialized. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct DiscriminatorPrefixed { + pub data: T, +} + +impl DiscriminatorPrefixed { + pub fn new(data: T) -> Self { + Self { data } + } +} + +impl BorshSerialize for DiscriminatorPrefixed +where + T: DiscriminatorData + borsh::BorshSerialize, +{ + fn serialize(&self, writer: &mut W) -> std::io::Result<()> { + PROGRAM_INSTRUCTION_DISCRIMINATOR.serialize(writer)?; + self.data.serialize(writer) + } +} + +impl BorshDeserialize for DiscriminatorPrefixed +where + T: DiscriminatorData + borsh::BorshDeserialize, +{ + fn deserialize(buf: &mut &[u8]) -> std::io::Result { + let (discriminator, rest) = buf.split_at(Discriminator::LENGTH); + if discriminator != PROGRAM_INSTRUCTION_DISCRIMINATOR { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Invalid discriminator", + )); + } + Ok(Self { + data: T::deserialize(&mut rest.to_vec().as_slice())?, + }) + } +} + +impl SizedData for DiscriminatorPrefixed +where + T: SizedData, +{ + fn size(&self) -> usize { + // 8 byte discriminator prefix + 8 + self.data.size() + } +} + +impl Deref for DiscriminatorPrefixed { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.data + } +} + +impl From for DiscriminatorPrefixed { + fn from(data: T) -> Self { + Self::new(data) + } +} + +pub trait DiscriminatorData: Sized { + const DISCRIMINATOR_LENGTH: usize = Discriminator::LENGTH; + + const DISCRIMINATOR: [u8; Discriminator::LENGTH]; + const DISCRIMINATOR_SLICE: &'static [u8] = &Self::DISCRIMINATOR; +} + +pub trait DiscriminatorEncode: DiscriminatorData + borsh::BorshSerialize { + fn encode(self) -> Result, ProgramError> { + let mut buf = vec![]; + buf.extend_from_slice(Self::DISCRIMINATOR_SLICE); + buf.extend_from_slice( + &self + .try_to_vec() + .map_err(|err| ProgramError::BorshIoError(err.to_string()))?[..], + ); + Ok(buf) + } +} + +// Auto-implement +impl DiscriminatorEncode for T where T: DiscriminatorData + borsh::BorshSerialize {} + +pub trait DiscriminatorDecode: DiscriminatorData + borsh::BorshDeserialize { + fn decode(data: &[u8]) -> Result { + let (discriminator, rest) = data.split_at(Discriminator::LENGTH); + if discriminator != Self::DISCRIMINATOR_SLICE { + return Err(ProgramError::InvalidInstructionData); + } + Self::try_from_slice(rest).map_err(|_| ProgramError::InvalidInstructionData) + } +} + +// Auto-implement +impl DiscriminatorDecode for T where T: DiscriminatorData + borsh::BorshDeserialize {} diff --git a/rust/sealevel/libraries/account-utils/src/lib.rs b/rust/sealevel/libraries/account-utils/src/lib.rs index 780faa7d0e..3fb53213da 100644 --- a/rust/sealevel/libraries/account-utils/src/lib.rs +++ b/rust/sealevel/libraries/account-utils/src/lib.rs @@ -7,7 +7,9 @@ use solana_program::{ rent::Rent, system_instruction, system_program, }; -use spl_type_length_value::discriminator::Discriminator; + +pub mod discriminator; +pub use discriminator::*; /// Data that has a predictable size when serialized. pub trait SizedData { @@ -33,14 +35,20 @@ pub struct AccountData { data: Box, } -impl From for AccountData { - fn from(data: T) -> Self { +impl AccountData { + pub fn new(data: T) -> Self { Self { data: Box::new(data), } } } +impl From for AccountData { + fn from(data: T) -> Self { + Self::new(data) + } +} + impl From> for AccountData { fn from(data: Box) -> Self { Self { data } @@ -140,6 +148,42 @@ where } } +impl AccountData +where + T: Data + SizedData, +{ + pub fn store_with_rent_exempt_realloc<'a, 'b>( + &self, + account_info: &'a AccountInfo<'b>, + rent: &Rent, + payer_info: &'a AccountInfo<'b>, + ) -> Result<(), ProgramError> { + let required_size = self.size(); + + let account_data_len = account_info.data_len(); + let required_account_data_len = required_size.max(account_data_len); + + let required_rent = rent.minimum_balance(required_account_data_len); + let lamports = account_info.lamports(); + if lamports < required_rent { + invoke( + &system_instruction::transfer( + payer_info.key, + account_info.key, + required_rent - lamports, + ), + &[payer_info.clone(), account_info.clone()], + )?; + } + + if account_data_len < required_account_data_len { + account_info.realloc(required_account_data_len, false)?; + } + + self.store(account_info, false) + } +} + /// Creates associated token account using Program Derived Address for the given seeds. /// Required to allow PDAs to be created even if they already have a lamport balance. /// @@ -216,41 +260,3 @@ pub fn verify_account_uninitialized(account: &AccountInfo) -> Result<(), Program } Err(ProgramError::AccountAlreadyInitialized) } - -pub const PROGRAM_INSTRUCTION_DISCRIMINATOR: [u8; Discriminator::LENGTH] = [1, 1, 1, 1, 1, 1, 1, 1]; - -pub trait DiscriminatorData: Sized { - const DISCRIMINATOR_LENGTH: usize = Discriminator::LENGTH; - - const DISCRIMINATOR: [u8; Discriminator::LENGTH]; - const DISCRIMINATOR_SLICE: &'static [u8] = &Self::DISCRIMINATOR; -} - -pub trait DiscriminatorEncode: DiscriminatorData + borsh::BorshSerialize { - fn encode(self) -> Result, ProgramError> { - let mut buf = vec![]; - buf.extend_from_slice(Self::DISCRIMINATOR_SLICE); - buf.extend_from_slice( - &self - .try_to_vec() - .map_err(|err| ProgramError::BorshIoError(err.to_string()))?[..], - ); - Ok(buf) - } -} - -// Auto-implement -impl DiscriminatorEncode for T where T: DiscriminatorData + borsh::BorshSerialize {} - -pub trait DiscriminatorDecode: DiscriminatorData + borsh::BorshDeserialize { - fn decode(data: &[u8]) -> Result { - let (discriminator, rest) = data.split_at(Discriminator::LENGTH); - if discriminator != Self::DISCRIMINATOR_SLICE { - return Err(ProgramError::InvalidInstructionData); - } - Self::try_from_slice(rest).map_err(|_| ProgramError::InvalidInstructionData) - } -} - -// Auto-implement -impl DiscriminatorDecode for T where T: DiscriminatorData + borsh::BorshDeserialize {} diff --git a/rust/sealevel/programs/interchain-gas-paymaster/Cargo.toml b/rust/sealevel/programs/interchain-gas-paymaster/Cargo.toml new file mode 100644 index 0000000000..6f087416c6 --- /dev/null +++ b/rust/sealevel/programs/interchain-gas-paymaster/Cargo.toml @@ -0,0 +1,30 @@ +cargo-features = ["workspace-inheritance"] + +[package] +name = "hyperlane-sealevel-igp" +version = "0.1.0" +edition = "2021" + +[features] +no-entrypoint = [] +no-spl-noop = [] + +[dependencies] +hyperlane-core = { path = "../../../hyperlane-core" } +access-control = { path = "../../libraries/access-control" } +account-utils = { path = "../../libraries/account-utils" } +serializable-account-meta = { path = "../../libraries/serializable-account-meta" } +borsh.workspace = true +solana-program.workspace = true +num-derive.workspace = true +num-traits.workspace = true +thiserror.workspace = true +getrandom.workspace = true + +[dev-dependencies] +solana-program-test = "1.14.13" +solana-sdk.workspace = true +hyperlane-test-utils ={ path = "../../libraries/test-utils" } + +[lib] +crate-type = ["cdylib", "lib"] diff --git a/rust/sealevel/programs/interchain-gas-paymaster/src/accounts.rs b/rust/sealevel/programs/interchain-gas-paymaster/src/accounts.rs new file mode 100644 index 0000000000..aaca278ec8 --- /dev/null +++ b/rust/sealevel/programs/interchain-gas-paymaster/src/accounts.rs @@ -0,0 +1,298 @@ +//! Interchain gas paymaster accounts. + +use std::{cmp::Ordering, collections::HashMap}; + +use access_control::AccessControl; +use account_utils::{AccountData, DiscriminatorData, DiscriminatorPrefixed, SizedData}; +use borsh::{BorshDeserialize, BorshSerialize}; +use solana_program::{clock::Slot, program_error::ProgramError, pubkey::Pubkey}; + +use hyperlane_core::{H256, U256}; + +use crate::error::Error; + +/// The scale for token exchange rates, i.e. a token exchange rate of 1.0 is +/// represented as 10^19. +pub const TOKEN_EXCHANGE_RATE_SCALE: u64 = 10u64.pow(19); +/// The number of decimals for the native SOL token. +pub const SOL_DECIMALS: u8 = 9; + +/// A gas oracle that provides gas data for a remote chain. +#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Clone)] +pub enum GasOracle { + /// Remote gas data stored directly in the variant data. + RemoteGasData(RemoteGasData), + // Future gas oracle variants could include a Pyth type, generalized CPI type, etc. +} + +impl Default for GasOracle { + fn default() -> Self { + GasOracle::RemoteGasData(RemoteGasData::default()) + } +} + +/// The account for the program's global data. +pub type ProgramDataAccount = AccountData; + +/// A singleton account that stores the program's global data. +#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Default)] +pub struct ProgramData { + /// The bump seed for the program data PDA. + pub bump_seed: u8, + /// The number of gas payments made by in the program. + pub payment_count: u64, +} + +impl SizedData for ProgramData { + fn size(&self) -> usize { + // 1 for bump_seed + // 8 for payment_count + 1 + 8 + } +} + +/// An overhead IGP account. +pub type OverheadIgpAccount = AccountData; + +/// Overhead IGP account data, intended to be configured with gas overheads +/// to impose on application-specified gas payment amounts. +#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Default)] +pub struct OverheadIgp { + /// The bump seed for the overhead IGP PDA. + pub bump_seed: u8, + /// The salt used to derive the overhead IGP PDA. + pub salt: H256, + /// The owner of the overhead IGP. + pub owner: Option, + /// The inner IGP account. + pub inner: Pubkey, + /// The gas overheads to impose on gas payments to each destination domain. + pub gas_overheads: HashMap, +} + +impl OverheadIgp { + /// Returns the gas overhead to impose on gas payments to the given + /// destination domain. Defaults to 0 if a gas overhead is not set for the domain. + pub fn gas_overhead(&self, destination_domain: u32) -> u64 { + self.gas_overheads + .get(&destination_domain) + .copied() + .unwrap_or(0) + } + + /// Quotes a gas payment, considering the gas overhead if one is present. + #[allow(unused)] + pub fn quote_gas_payment( + &self, + destination_domain: u32, + gas_amount: u64, + inner_igp: &Igp, + ) -> Result { + let total_gas_amount = self.gas_overhead(destination_domain) + gas_amount; + inner_igp.quote_gas_payment(destination_domain, total_gas_amount) + } +} + +impl AccessControl for OverheadIgp { + fn owner(&self) -> Option<&Pubkey> { + self.owner.as_ref() + } + + fn set_owner(&mut self, new_owner: Option) -> Result<(), ProgramError> { + self.owner = new_owner; + Ok(()) + } +} + +impl SizedData for OverheadIgp { + fn size(&self) -> usize { + // 1 for bump_seed + // 32 for salt + // 33 for owner (1 byte Option, 32 bytes for pubkey) + // 32 for inner + // 4 for gas_overheads.len() + // N * (4 + 8) for gas_overhead contents + 1 + 32 + 33 + 32 + 4 + (self.gas_overheads.len() * (4 + 8)) + } +} + +/// An IGP account. +pub type IgpAccount = AccountData; + +/// IGP account data. +#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Default)] +pub struct Igp { + /// The bump seed for the IGP PDA. + pub bump_seed: u8, + /// The salt used to derive the IGP PDA. + pub salt: H256, + /// The owner of the IGP. + pub owner: Option, + /// The beneficiary of the IGP. + pub beneficiary: Pubkey, + /// The gas oracles for each destination domain. + pub gas_oracles: HashMap, +} + +impl SizedData for Igp { + fn size(&self) -> usize { + // 1 for bump_seed + // 32 for salt + // 33 for owner (1 byte Option, 32 bytes for pubkey) + // 32 for beneficiary + // 4 for gas_oracles.len() + // M * (4 + (1 + 257)) for gas_oracles contents + 1 + 32 + 33 + 32 + 4 + (self.gas_oracles.len() * (1 + 257)) + } +} + +impl Igp { + /// Quotes a gas payment. + /// Returns an error if a gas oracle is not set for the destination domain. + pub fn quote_gas_payment( + &self, + destination_domain: u32, + gas_amount: u64, + ) -> Result { + let oracle = self + .gas_oracles + .get(&destination_domain) + .ok_or(Error::NoGasOracleSetForDestinationDomain)?; + let GasOracle::RemoteGasData(RemoteGasData { + token_exchange_rate, + gas_price, + token_decimals, + }) = oracle; + + // Arithmetic is done using U256 to avoid overflows. + + // The total cost quoted in the destination chain's native token. + let destination_gas_cost = U256::from(gas_amount) * U256::from(*gas_price); + + // Convert to the local native token (decimals not yet accounted for). + let origin_cost = (destination_gas_cost * U256::from(*token_exchange_rate)) + / U256::from(TOKEN_EXCHANGE_RATE_SCALE); + + // Convert from the remote token's decimals to the local token's decimals. + let origin_cost = convert_decimals(origin_cost, *token_decimals, SOL_DECIMALS); + + // Panics if an overflow occurs. + Ok(origin_cost.as_u64()) + } +} + +impl AccessControl for Igp { + fn owner(&self) -> Option<&Pubkey> { + self.owner.as_ref() + } + + fn set_owner(&mut self, new_owner: Option) -> Result<(), ProgramError> { + self.owner = new_owner; + Ok(()) + } +} + +/// Remote gas data. +#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Default, Clone)] +pub struct RemoteGasData { + /// The token exchange rate for the remote token, adjusted by the + /// TOKEN_EXCHANGE_RATE_SCALE. + /// If this e.g. 0.2, then one local token would give you 5 remote tokens. + pub token_exchange_rate: u128, + /// The gas price for the remote chain. + pub gas_price: u128, + /// The number of decimals for the remote token. + pub token_decimals: u8, +} + +/// A discriminator used to easily identify gas payment accounts. +/// This is the first 8 bytes of the account data. +pub const GAS_PAYMENT_DISCRIMINATOR: &[u8; 8] = b"GASPAYMT"; + +/// A gas payment account, relating to a single gas payment. +pub type GasPaymentAccount = AccountData; + +/// Gas payment account data, prefixed with a discriminator. +pub type GasPayment = DiscriminatorPrefixed; + +impl DiscriminatorData for GasPaymentData { + const DISCRIMINATOR: [u8; 8] = *GAS_PAYMENT_DISCRIMINATOR; +} + +/// Gas payment account data. +#[derive(BorshSerialize, BorshDeserialize, Debug, PartialEq, Default)] +pub struct GasPaymentData { + /// The sequence number of the gas payment. + pub sequence_number: u64, + /// The IGP that the gas payment is for. + pub igp: Pubkey, + /// The destination domain of the gas payment. + pub destination_domain: u32, + /// The message ID of the gas payment. + pub message_id: H256, + /// The amount of gas paid for. + pub gas_amount: u64, + /// The slot of the gas payment. + pub slot: Slot, +} + +impl SizedData for GasPaymentData { + fn size(&self) -> usize { + // 8 for sequence_number + // 32 for igp + // 4 for destination_domain + // 32 for message_id + // 8 for gas_amount + // 8 for slot + 8 + 32 + 4 + 32 + 8 + 8 + } +} + +/// Converts `num` from `from_decimals` to `to_decimals`. +fn convert_decimals(num: U256, from_decimals: u8, to_decimals: u8) -> U256 { + match from_decimals.cmp(&to_decimals) { + Ordering::Greater => num / U256::from(10u64).pow(U256::from(from_decimals - to_decimals)), + Ordering::Less => num * U256::from(10u64).pow(U256::from(to_decimals - from_decimals)), + Ordering::Equal => num, + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_convert_decimals() { + let num = U256::from(1000000u128); + let from_decimals = 9; + let to_decimals = 9; + let result = convert_decimals(num, from_decimals, to_decimals); + assert_eq!(result, num); + + let num = U256::from(1000000000000000u128); + let from_decimals = 18; + let to_decimals = 9; + let result = convert_decimals(num, from_decimals, to_decimals); + assert_eq!(result, U256::from(1000000u128)); + + let num = U256::from(1000000u128); + let from_decimals = 4; + let to_decimals = 9; + let result = convert_decimals(num, from_decimals, to_decimals); + assert_eq!(result, U256::from(100000000000u128)); + + // Some loss of precision + let num = U256::from(9999999u128); + let from_decimals = 9; + let to_decimals = 4; + let result = convert_decimals(num, from_decimals, to_decimals); + assert_eq!(result, U256::from(99u128)); + + // Total loss of precision + let num = U256::from(999u128); + let from_decimals = 9; + let to_decimals = 4; + let result = convert_decimals(num, from_decimals, to_decimals); + assert_eq!(result, U256::from(0u128)); + } +} diff --git a/rust/sealevel/programs/interchain-gas-paymaster/src/error.rs b/rust/sealevel/programs/interchain-gas-paymaster/src/error.rs new file mode 100644 index 0000000000..47f60b2ba2 --- /dev/null +++ b/rust/sealevel/programs/interchain-gas-paymaster/src/error.rs @@ -0,0 +1,18 @@ +//! Hyperlane Sealevel Mailbox custom errors. + +use solana_program::program_error::ProgramError; + +/// Custom errors type for the Mailbox program. +#[derive(Copy, Clone, Debug, Eq, thiserror::Error, num_derive::FromPrimitive, PartialEq)] +#[repr(u32)] +pub enum Error { + /// No gas oracle set for destination domain. + #[error("No gas oracle set for destination domain")] + NoGasOracleSetForDestinationDomain = 1, +} + +impl From for ProgramError { + fn from(err: Error) -> Self { + ProgramError::Custom(err as u32) + } +} diff --git a/rust/sealevel/programs/interchain-gas-paymaster/src/instruction.rs b/rust/sealevel/programs/interchain-gas-paymaster/src/instruction.rs new file mode 100644 index 0000000000..73d33a446f --- /dev/null +++ b/rust/sealevel/programs/interchain-gas-paymaster/src/instruction.rs @@ -0,0 +1,95 @@ +//! Program instructions. + +use borsh::{BorshDeserialize, BorshSerialize}; +use hyperlane_core::H256; + +use solana_program::pubkey::Pubkey; + +use crate::accounts::GasOracle; + +/// The program instructions. +#[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq)] +pub enum Instruction { + /// Initializes the program. + Init, + /// Initializes an IGP. + InitIgp(InitIgp), + /// Initializes an overhead IGP. + InitOverheadIgp(InitOverheadIgp), + /// Pays for gas. + PayForGas(PayForGas), + /// Quotes a gas payment. + QuoteGasPayment(QuoteGasPayment), + /// Transfers ownership of an IGP. + TransferIgpOwnership(Option), + /// Transfers ownership of an overhead IGP. + TransferOverheadIgpOwnership(Option), + /// Sets the beneficiary of an IGP. + SetIgpBeneficiary(Pubkey), + /// Sets destination gas overheads on an overhead IGP. + SetDestinationGasOverheads(Vec), + /// Sets gas oracles on an IGP. + SetGasOracleConfigs(Vec), + /// Claims lamports from an IGP, sending them to the IGP's beneficiary. + Claim, +} + +/// Initializes an IGP. +#[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq)] +pub struct InitIgp { + /// A salt used for deriving the IGP PDA. + pub salt: H256, + /// The owner of the IGP. + pub owner: Option, + /// The beneficiary of the IGP. + pub beneficiary: Pubkey, +} + +/// Initializes an overhead IGP. +#[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq)] +pub struct InitOverheadIgp { + /// A salt used for deriving the overhead IGP PDA. + pub salt: H256, + /// The owner of the overhead IGP. + pub owner: Option, + /// The inner IGP. + pub inner: Pubkey, +} + +/// Pays for gas. +#[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq)] +pub struct PayForGas { + /// The message ID. + pub message_id: H256, + /// The destination domain. + pub destination_domain: u32, + /// The gas amount. + pub gas_amount: u64, +} + +/// Quotes a gas payment. +#[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq)] +pub struct QuoteGasPayment { + /// The destination domain. + pub destination_domain: u32, + /// The gas amount. + pub gas_amount: u64, +} + +/// A config for setting a destination gas overhead. +#[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq, Clone)] +pub struct GasOverheadConfig { + /// The destination domain. + pub destination_domain: u32, + /// The gas overhead. + pub gas_overhead: Option, +} + +/// A config for setting remote gas data. +#[derive(BorshDeserialize, BorshSerialize, Debug, PartialEq, Clone)] +pub struct GasOracleConfig { + /// The destination domain. + pub domain: u32, + /// The gas oracle. + pub gas_oracle: Option, +} diff --git a/rust/sealevel/programs/interchain-gas-paymaster/src/lib.rs b/rust/sealevel/programs/interchain-gas-paymaster/src/lib.rs new file mode 100644 index 0000000000..4589dabfab --- /dev/null +++ b/rust/sealevel/programs/interchain-gas-paymaster/src/lib.rs @@ -0,0 +1,11 @@ +//! Program to pay for gas fees for messages sent to remote chains. + +#![deny(warnings)] +#![deny(missing_docs)] +#![deny(unsafe_code)] + +pub mod accounts; +pub mod error; +pub mod instruction; +pub mod pda_seeds; +pub mod processor; diff --git a/rust/sealevel/programs/interchain-gas-paymaster/src/pda_seeds.rs b/rust/sealevel/programs/interchain-gas-paymaster/src/pda_seeds.rs new file mode 100644 index 0000000000..0e6dae12d5 --- /dev/null +++ b/rust/sealevel/programs/interchain-gas-paymaster/src/pda_seeds.rs @@ -0,0 +1,83 @@ +//! Program PDA seeds. + +/// Gets the PDA seeds for the singleton program data. +#[macro_export] +macro_rules! igp_program_data_pda_seeds { + () => {{ + &[b"hyperlane_igp", b"-", b"program_data"] + }}; + + ($bump_seed:expr) => {{ + &[b"hyperlane_igp", b"-", b"program_data", &[$bump_seed]] + }}; +} + +/// Gets the PDA seeds for an IGP account. +#[macro_export] +macro_rules! igp_pda_seeds { + ($salt:expr) => {{ + &[b"hyperlane_igp", b"-", b"igp", b"-", $salt.as_ref()] + }}; + + ($salt:expr, $bump_seed:expr) => {{ + &[ + b"hyperlane_igp", + b"-", + b"igp", + b"-", + $salt.as_ref(), + &[$bump_seed], + ] + }}; +} + +/// Gets the PDA seeds for an Overhead IGP account. +#[macro_export] +macro_rules! overhead_igp_pda_seeds { + ($salt:expr) => {{ + &[ + b"hyperlane_igp", + b"-", + b"overhead_igp", + b"-", + $salt.as_ref(), + ] + }}; + + ($salt:expr, $bump_seed:expr) => {{ + &[ + b"hyperlane_igp", + b"-", + b"overhead_igp", + b"-", + $salt.as_ref(), + &[$bump_seed], + ] + }}; +} + +/// Gets the PDA seeds for an IGP gas payment account that's based upon +/// the pubkey of a unique message account for uniqueness. +#[macro_export] +macro_rules! igp_gas_payment_pda_seeds { + ($unique_gas_payment_pubkey:expr) => {{ + &[ + b"hyperlane_igp", + b"-", + b"gas_payment", + b"-", + $unique_gas_payment_pubkey.as_ref(), + ] + }}; + + ($unique_gas_payment_pubkey:expr, $bump_seed:expr) => {{ + &[ + b"hyperlane_igp", + b"-", + b"gas_payment", + b"-", + $unique_gas_payment_pubkey.as_ref(), + &[$bump_seed], + ] + }}; +} diff --git a/rust/sealevel/programs/interchain-gas-paymaster/src/processor.rs b/rust/sealevel/programs/interchain-gas-paymaster/src/processor.rs new file mode 100644 index 0000000000..eb5e010b74 --- /dev/null +++ b/rust/sealevel/programs/interchain-gas-paymaster/src/processor.rs @@ -0,0 +1,654 @@ +//! Program state processor. + +use borsh::{BorshDeserialize, BorshSerialize}; +use std::collections::HashMap; + +#[cfg(not(feature = "no-entrypoint"))] +use solana_program::entrypoint; +use solana_program::{ + account_info::{next_account_info, AccountInfo}, + clock::Clock, + entrypoint::ProgramResult, + msg, + program::{invoke, set_return_data}, + program_error::ProgramError, + pubkey::Pubkey, + rent::Rent, + system_instruction, + sysvar::Sysvar, +}; + +use access_control::AccessControl; +use account_utils::{ + create_pda_account, verify_account_uninitialized, verify_rent_exempt, AccountData, SizedData, +}; +use serializable_account_meta::SimulationReturnData; + +use crate::{ + accounts::{ + GasPaymentAccount, GasPaymentData, Igp, IgpAccount, OverheadIgp, OverheadIgpAccount, + ProgramData, ProgramDataAccount, + }, + igp_gas_payment_pda_seeds, igp_pda_seeds, igp_program_data_pda_seeds, + instruction::{ + GasOracleConfig, GasOverheadConfig, InitIgp, InitOverheadIgp, + Instruction as IgpInstruction, PayForGas, QuoteGasPayment, + }, + overhead_igp_pda_seeds, +}; + +#[cfg(not(feature = "no-entrypoint"))] +entrypoint!(process_instruction); + +/// Entrypoint for the IGP program. +pub fn process_instruction( + program_id: &Pubkey, + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> ProgramResult { + match IgpInstruction::try_from_slice(instruction_data)? { + IgpInstruction::Init => { + init(program_id, accounts)?; + } + IgpInstruction::InitIgp(data) => { + init_igp(program_id, accounts, data)?; + } + IgpInstruction::InitOverheadIgp(data) => { + init_overhead_igp(program_id, accounts, data)?; + } + IgpInstruction::PayForGas(payment) => { + pay_for_gas(program_id, accounts, payment)?; + } + IgpInstruction::QuoteGasPayment(payment) => { + quote_gas_payment(program_id, accounts, payment)?; + } + IgpInstruction::TransferIgpOwnership(new_owner) => { + transfer_igp_variant_ownership::(program_id, accounts, new_owner)?; + } + IgpInstruction::TransferOverheadIgpOwnership(new_owner) => { + transfer_igp_variant_ownership::(program_id, accounts, new_owner)?; + } + IgpInstruction::SetIgpBeneficiary(beneficiary) => { + set_igp_beneficiary(program_id, accounts, beneficiary)?; + } + IgpInstruction::Claim => { + claim(program_id, accounts)?; + } + IgpInstruction::SetDestinationGasOverheads(configs) => { + set_destination_gas_overheads(program_id, accounts, configs)?; + } + IgpInstruction::SetGasOracleConfigs(configs) => { + set_gas_oracle_configs(program_id, accounts, configs)?; + } + } + + Ok(()) +} + +/// Initializes the program. +/// +/// Accounts: +/// 0. [executable] The system program. +/// 1. [signer] The payer account. +/// 2. [writeable] The program data PDA account. +fn init(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + // Account 0: The system program. + let system_program_info = next_account_info(accounts_iter)?; + if *system_program_info.key != solana_program::system_program::id() { + return Err(ProgramError::IncorrectProgramId); + } + + // Account 1: The payer account. + let payer_info = next_account_info(accounts_iter)?; + if !payer_info.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + + // Account 2: The program data account. + let program_data_info = next_account_info(accounts_iter)?; + verify_account_uninitialized(program_data_info)?; + let (program_data_key, program_data_bump) = + Pubkey::find_program_address(igp_program_data_pda_seeds!(), program_id); + if *program_data_info.key != program_data_key { + return Err(ProgramError::InvalidSeeds); + } + + let program_data_account = ProgramDataAccount::from(ProgramData { + bump_seed: program_data_bump, + payment_count: 0, + }); + // Create the program data PDA account. + let program_data_account_size = program_data_account.size(); + + let rent = Rent::get()?; + + create_pda_account( + payer_info, + &rent, + program_data_account_size, + program_id, + system_program_info, + program_data_info, + igp_program_data_pda_seeds!(program_data_bump), + )?; + + // Store the program data. + program_data_account.store(program_data_info, false)?; + + Ok(()) +} + +/// Initialize a new IGP account. +/// +/// Accounts: +/// 0. [executable] The system program. +/// 1. [signer] The payer account. +/// 2. [writeable] The IGP account to initialize. +fn init_igp(program_id: &Pubkey, accounts: &[AccountInfo], data: InitIgp) -> ProgramResult { + let igp_key = init_igp_variant( + program_id, + accounts, + |bump_seed| Igp { + bump_seed, + salt: data.salt, + owner: data.owner, + beneficiary: data.beneficiary, + gas_oracles: HashMap::new(), + }, + igp_pda_seeds!(data.salt), + )?; + + msg!("Initialized IGP: {}", igp_key); + + Ok(()) +} + +/// Initialize a new overhead IGP account. +/// +/// Accounts: +/// 0. [executable] The system program. +/// 1. [signer] The payer account. +/// 2. [writeable] The Overhead IGP account to initialize. +fn init_overhead_igp( + program_id: &Pubkey, + accounts: &[AccountInfo], + data: InitOverheadIgp, +) -> ProgramResult { + let igp_key = init_igp_variant( + program_id, + accounts, + |bump_seed| OverheadIgp { + bump_seed, + salt: data.salt, + owner: data.owner, + inner: data.inner, + gas_overheads: HashMap::new(), + }, + overhead_igp_pda_seeds!(data.salt), + )?; + + msg!("Initialized Overhead IGP: {}", igp_key); + + Ok(()) +} + +/// Initializes an IGP variant. +fn init_igp_variant( + program_id: &Pubkey, + accounts: &[AccountInfo], + get_data: impl FnOnce(u8) -> T, + pda_seeds: &[&[u8]], +) -> Result { + let accounts_iter = &mut accounts.iter(); + + // Account 0: The system program. + let system_program_info = next_account_info(accounts_iter)?; + if *system_program_info.key != solana_program::system_program::id() { + return Err(ProgramError::IncorrectProgramId); + } + + // Account 1: The payer account and owner of the IGP account. + let payer_info = next_account_info(accounts_iter)?; + if !payer_info.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + + // Account 2: The Overhead IGP account to initialize. + let igp_info = next_account_info(accounts_iter)?; + verify_account_uninitialized(igp_info)?; + let (igp_key, igp_bump) = Pubkey::find_program_address(pda_seeds, program_id); + if *igp_info.key != igp_key { + return Err(ProgramError::InvalidSeeds); + } + + let igp_account = AccountData::::from(get_data(igp_bump)); + + let igp_account_size = igp_account.size(); + + let rent = Rent::get()?; + + create_pda_account( + payer_info, + &rent, + igp_account_size, + program_id, + system_program_info, + igp_info, + &[pda_seeds, &[&[igp_bump]]].concat(), + )?; + + // Store the IGP account. + igp_account.store(igp_info, false)?; + + Ok(*igp_info.key) +} + +/// Pay for gas. +/// +/// Accounts: +/// 0. [executable] The system program. +/// 1. [signer] The payer. +/// 2. [writeable] The IGP program data. +/// 3. [writeable] The IGP account. +/// 4. [signer] Unique gas payment account. +/// 5. [writeable] Gas payment PDA. +/// 6. [] Overhead IGP account (optional). +fn pay_for_gas(program_id: &Pubkey, accounts: &[AccountInfo], payment: PayForGas) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + // Account 0: The system program. + let system_program_info = next_account_info(accounts_iter)?; + if *system_program_info.key != solana_program::system_program::id() { + return Err(ProgramError::IncorrectProgramId); + } + + // Account 1: The payer account. + let payer_info = next_account_info(accounts_iter)?; + if !payer_info.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + + // Account 2: The IGP program data. + let program_data_info = next_account_info(accounts_iter)?; + let mut program_data = + ProgramDataAccount::fetch(&mut &program_data_info.data.borrow()[..])?.into_inner(); + let expected_program_data_key = Pubkey::create_program_address( + igp_program_data_pda_seeds!(program_data.bump_seed), + program_id, + )?; + if program_data_info.key != &expected_program_data_key { + return Err(ProgramError::InvalidSeeds); + } + if program_data_info.owner != program_id { + return Err(ProgramError::IncorrectProgramId); + } + + // Account 3: The IGP account. + let igp_info = next_account_info(accounts_iter)?; + // The caller should validate the IGP account before paying for gas, + // but we do a basic sanity check. + if igp_info.owner != program_id { + return Err(ProgramError::IncorrectProgramId); + } + let igp = IgpAccount::fetch(&mut &igp_info.data.borrow()[..])?.into_inner(); + let igp_key = + Pubkey::create_program_address(igp_pda_seeds!(igp.salt, igp.bump_seed), program_id)?; + if igp_info.key != &igp_key { + return Err(ProgramError::InvalidSeeds); + } + + // Account 4: The unique gas payment account. + // Uniqueness is enforced by making sure the message storage PDA based on + // this unique message account is empty, which is done next. + let unique_message_account_info = next_account_info(accounts_iter)?; + if !unique_message_account_info.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + + // Account 5: Gas payment PDA. + let gas_payment_account_info = next_account_info(accounts_iter)?; + let (gas_payment_key, gas_payment_bump) = Pubkey::find_program_address( + igp_gas_payment_pda_seeds!(unique_message_account_info.key), + program_id, + ); + if gas_payment_account_info.key != &gas_payment_key { + return Err(ProgramError::InvalidSeeds); + } + // Make sure an account can't be written to that already exists. + verify_account_uninitialized(gas_payment_account_info)?; + + // Account 6: Overhead IGP account (optional). + // The caller is expected to only provide an overhead IGP they are comfortable + // with / have configured themselves. + let gas_amount = if let Some(overhead_igp_info) = accounts_iter.next() { + if overhead_igp_info.owner != program_id { + return Err(ProgramError::IncorrectProgramId); + } + + let overhead_igp = + OverheadIgpAccount::fetch(&mut &overhead_igp_info.data.borrow()[..])?.into_inner(); + let overhead_igp_key = Pubkey::create_program_address( + overhead_igp_pda_seeds!(overhead_igp.salt, overhead_igp.bump_seed), + program_id, + )?; + if overhead_igp_key != *overhead_igp_info.key || overhead_igp.inner != *igp_info.key { + return Err(ProgramError::InvalidArgument); + } + + overhead_igp.gas_overhead(payment.destination_domain) + payment.gas_amount + } else { + payment.gas_amount + }; + + let required_payment = igp.quote_gas_payment(payment.destination_domain, gas_amount)?; + + // Transfer the required payment to the IGP. + invoke( + &system_instruction::transfer(payer_info.key, igp_info.key, required_payment), + &[payer_info.clone(), igp_info.clone()], + )?; + + let gas_payment_account = GasPaymentAccount::new( + GasPaymentData { + sequence_number: program_data.payment_count, + igp: *igp_info.key, + destination_domain: payment.destination_domain, + message_id: payment.message_id, + gas_amount, + slot: Clock::get()?.slot, + } + .into(), + ); + let gas_payment_account_size = gas_payment_account.size(); + + let rent = Rent::get()?; + + create_pda_account( + payer_info, + &rent, + gas_payment_account_size, + program_id, + system_program_info, + gas_payment_account_info, + igp_gas_payment_pda_seeds!(unique_message_account_info.key, gas_payment_bump), + )?; + + gas_payment_account.store(gas_payment_account_info, false)?; + + // Increment the payment count and update the program data. + program_data.payment_count += 1; + ProgramDataAccount::from(program_data).store(program_data_info, false)?; + + msg!( + "Paid IGP {} for {} gas for message {} to {}", + igp_key, + gas_amount, + payment.message_id, + payment.destination_domain + ); + + Ok(()) +} + +/// Quotes the required payment for a given gas amount and destination domain. +/// +/// Accounts: +/// 0. [executable] The system program. +/// 1. [] The IGP account. +/// 2. [] The overhead IGP account (optional). +fn quote_gas_payment( + program_id: &Pubkey, + accounts: &[AccountInfo], + payment: QuoteGasPayment, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + // Account 0: The system program. + let system_program_info = next_account_info(accounts_iter)?; + if *system_program_info.key != solana_program::system_program::id() { + return Err(ProgramError::IncorrectProgramId); + } + + // Account 1: The IGP account. + let igp_info = next_account_info(accounts_iter)?; + // The caller should validate the IGP account before paying for gas, + // but we do some basic checks here as a sanity check. + if igp_info.owner != program_id { + return Err(ProgramError::IncorrectProgramId); + } + + // Account 2: Overhead IGP account (optional). + // The caller is expected to only provide an overhead IGP they are comfortable + // with / have configured themselves. + let gas_amount = if let Some(overhead_igp_info) = accounts_iter.next() { + if overhead_igp_info.owner != program_id { + return Err(ProgramError::IncorrectProgramId); + } + + let overhead_igp = + OverheadIgpAccount::fetch(&mut &overhead_igp_info.data.borrow()[..])?.into_inner(); + + if overhead_igp.inner != *igp_info.key { + return Err(ProgramError::InvalidArgument); + } + + overhead_igp.gas_overhead(payment.destination_domain) + payment.gas_amount + } else { + payment.gas_amount + }; + + let igp = IgpAccount::fetch(&mut &igp_info.data.borrow()[..])?.into_inner(); + + let required_payment = igp.quote_gas_payment(payment.destination_domain, gas_amount)?; + + set_return_data(&SimulationReturnData::new(required_payment).try_to_vec()?); + + Ok(()) +} + +/// Sets the beneficiary of an IGP. +/// +/// Accounts: +/// 0. [] The IGP. +/// 1. [signer] The owner of the IGP account. +fn set_igp_beneficiary( + program_id: &Pubkey, + accounts: &[AccountInfo], + beneficiary: Pubkey, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + let (igp_info, mut igp, _) = + get_igp_variant_and_verify_owner::(program_id, accounts_iter)?; + + // Update the beneficiary and store it. + igp.beneficiary = beneficiary; + IgpAccount::from(igp).store(igp_info, false)?; + + Ok(()) +} + +/// Transfers ownership of an IGP variant. +/// +/// Accounts: +/// 0. [] The IGP or OverheadIGP. +/// 1. [signer] The owner of the IGP account. +fn transfer_igp_variant_ownership< + T: account_utils::Data + account_utils::SizedData + AccessControl, +>( + program_id: &Pubkey, + accounts: &[AccountInfo], + new_owner: Option, +) -> Result<(), ProgramError> { + let accounts_iter = &mut accounts.iter(); + + let (igp_info, mut igp, _) = get_igp_variant_and_verify_owner::(program_id, accounts_iter)?; + + // Update the owner and store it. + igp.set_owner(new_owner)?; + AccountData::::from(igp).store(igp_info, false)?; + + Ok(()) +} + +/// Gets an IGP variant and verifies the owner. +/// +/// Accounts: +/// 0. [] The IGP variant. +/// 1. [signer] The owner of the IGP variant. +fn get_igp_variant_and_verify_owner< + 'a, + 'b, + T: account_utils::Data + account_utils::SizedData + AccessControl, +>( + program_id: &Pubkey, + accounts_iter: &mut std::slice::Iter<'a, AccountInfo<'b>>, +) -> Result<(&'a AccountInfo<'b>, T, &'a AccountInfo<'b>), ProgramError> { + // Account 0: The IGP or OverheadIGP account. + let igp_info = next_account_info(accounts_iter)?; + if igp_info.owner != program_id { + return Err(ProgramError::IncorrectProgramId); + } + + let igp = AccountData::::fetch(&mut &igp_info.data.borrow()[..])?.into_inner(); + + // Account 1: The owner of the IGP account. + let owner_info = next_account_info(accounts_iter)?; + // Errors if `owner_info` is not a signer or is not the current owner. + igp.ensure_owner_signer(owner_info)?; + + Ok((igp_info, *igp, owner_info)) +} + +/// Sends funds accrued in an IGP to its beneficiary. +/// +/// Accounts: +/// 0. [executable] The system program. +/// 1. [writeable] The IGP. +/// 2. [writeable] The IGP beneficiary. +fn claim(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + // Account 0: The system program. + let system_program_info = next_account_info(accounts_iter)?; + if *system_program_info.key != solana_program::system_program::id() { + return Err(ProgramError::IncorrectProgramId); + } + + // Account 1: The IGP. + let igp_info = next_account_info(accounts_iter)?; + if igp_info.owner != program_id { + return Err(ProgramError::IncorrectProgramId); + } + let igp = IgpAccount::fetch(&mut &igp_info.data.borrow()[..])?.into_inner(); + let expected_igp_key = + Pubkey::create_program_address(igp_pda_seeds!(igp.salt, igp.bump_seed), program_id)?; + if igp_info.key != &expected_igp_key { + return Err(ProgramError::InvalidSeeds); + } + + // Account 2: The IGP beneficiary. + let igp_beneficiary = next_account_info(accounts_iter)?; + if igp_beneficiary.key != &igp.beneficiary { + return Err(ProgramError::InvalidArgument); + } + + let rent = Rent::get()?; + + let required_balance = rent.minimum_balance(igp_info.data_len()); + + let transfer_amount = igp_info.lamports().saturating_sub(required_balance); + **igp_info.try_borrow_mut_lamports()? -= transfer_amount; + **igp_beneficiary.try_borrow_mut_lamports()? += transfer_amount; + + // For good measure... + verify_rent_exempt(igp_info, &rent)?; + + Ok(()) +} + +/// Sets destination gas overheads for an OverheadIGP. +/// +/// Accounts: +/// 0. [executable] The system program. +/// 1. [writeable] The OverheadIGP. +/// 2. [signer] The OverheadIGP owner. +fn set_destination_gas_overheads( + program_id: &Pubkey, + accounts: &[AccountInfo], + configs: Vec, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + // Account 0: System program. + // Required to invoke `system_instruction::transfer` in `store_with_rent_exempt_realloc`. + let system_program_info = next_account_info(accounts_iter)?; + if system_program_info.key != &solana_program::system_program::id() { + return Err(ProgramError::IncorrectProgramId); + } + + // Errors if `owner_info` is not a signer or is not the current owner. + let (overhead_igp_info, mut overhead_igp, owner_info) = + get_igp_variant_and_verify_owner::(program_id, accounts_iter)?; + + configs.into_iter().for_each(|config| { + match config.gas_overhead { + Some(gas_overhead) => overhead_igp + .gas_overheads + .insert(config.destination_domain, gas_overhead), + None => overhead_igp + .gas_overheads + .remove(&config.destination_domain), + }; + }); + + let overhead_igp_account = OverheadIgpAccount::from(overhead_igp); + + overhead_igp_account.store_with_rent_exempt_realloc( + overhead_igp_info, + &Rent::get()?, + owner_info, + )?; + + Ok(()) +} + +/// Sets gas oracle configs for an IGP. +/// +/// Accounts: +/// 0. [executable] The system program. +/// 1. [writeable] The IGP. +/// 2. [signer] The IGP owner. +fn set_gas_oracle_configs( + program_id: &Pubkey, + accounts: &[AccountInfo], + configs: Vec, +) -> ProgramResult { + let accounts_iter = &mut accounts.iter(); + + // Account 0: System program. + // Required to invoke `system_instruction::transfer` in `store_with_rent_exempt_realloc`. + let system_program_info = next_account_info(accounts_iter)?; + if system_program_info.key != &solana_program::system_program::id() { + return Err(ProgramError::IncorrectProgramId); + } + + // Errors if `owner_info` is not a signer or is not the current owner. + let (igp_info, mut igp, owner_info) = + get_igp_variant_and_verify_owner::(program_id, accounts_iter)?; + + configs.into_iter().for_each(|config| { + match config.gas_oracle { + Some(gas_oracle) => igp.gas_oracles.insert(config.domain, gas_oracle), + None => igp.gas_oracles.remove(&config.domain), + }; + }); + + let igp_account = IgpAccount::from(igp); + + igp_account.store_with_rent_exempt_realloc(igp_info, &Rent::get()?, owner_info)?; + + Ok(()) +} diff --git a/rust/sealevel/programs/interchain-gas-paymaster/tests/functional.rs b/rust/sealevel/programs/interchain-gas-paymaster/tests/functional.rs new file mode 100644 index 0000000000..7f911eea5e --- /dev/null +++ b/rust/sealevel/programs/interchain-gas-paymaster/tests/functional.rs @@ -0,0 +1,1577 @@ +use hyperlane_core::H256; + +use std::collections::HashMap; + +use solana_program::{ + instruction::{AccountMeta, Instruction}, + pubkey, + pubkey::Pubkey, + system_program, + sysvar::rent::Rent, +}; +use solana_program_test::*; +use solana_sdk::{ + instruction::InstructionError, signature::Signature, signature::Signer, + signer::keypair::Keypair, transaction::TransactionError, +}; + +use hyperlane_test_utils::{ + assert_transaction_error, new_funded_keypair, process_instruction, simulate_instruction, + transfer_lamports, +}; +use serializable_account_meta::SimulationReturnData; + +use access_control::AccessControl; +use account_utils::{AccountData, Data}; +use hyperlane_sealevel_igp::{ + accounts::{ + GasOracle, GasPaymentAccount, GasPaymentData, Igp, IgpAccount, OverheadIgp, + OverheadIgpAccount, ProgramData, ProgramDataAccount, RemoteGasData, + }, + error::Error as IgpError, + igp_gas_payment_pda_seeds, igp_pda_seeds, igp_program_data_pda_seeds, + instruction::{ + GasOracleConfig, GasOverheadConfig, InitIgp, InitOverheadIgp, + Instruction as IgpInstruction, PayForGas, QuoteGasPayment, + }, + overhead_igp_pda_seeds, + processor::process_instruction as igp_process_instruction, +}; + +const TEST_DESTINATION_DOMAIN: u32 = 11111; +const TEST_GAS_AMOUNT: u64 = 300000; +const TEST_GAS_OVERHEAD_AMOUNT: u64 = 100000; +const TOKEN_EXCHANGE_RATE_SCALE: u128 = 1e19 as u128; +const LOCAL_DECIMALS: u8 = 9; + +fn igp_program_id() -> Pubkey { + pubkey!("BSffRJEwRcyEkjnbjAMMfv9kv3Y3SauxsBjCdNJyM2BN") +} + +async fn setup_client() -> (BanksClient, Keypair) { + let program_id = igp_program_id(); + let program_test = ProgramTest::new( + "hyperlane_sealevel_igp", + program_id, + processor!(igp_process_instruction), + ); + + let (banks_client, payer, _recent_blockhash) = program_test.start().await; + + (banks_client, payer) +} + +async fn initialize( + banks_client: &mut BanksClient, + payer: &Keypair, +) -> Result<(Pubkey, u8), BanksClientError> { + let program_id = igp_program_id(); + + let (program_data_key, program_data_bump_seed) = + Pubkey::find_program_address(igp_program_data_pda_seeds!(), &program_id); + + // Accounts: + // 0. [executable] The system program. + // 1. [signer] The payer account. + // 2. [writeable] The program data account. + let init_instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::Init, + vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(payer.pubkey(), true), + AccountMeta::new(program_data_key, false), + ], + ); + + process_instruction(banks_client, init_instruction, payer, &[payer]).await?; + + Ok((program_data_key, program_data_bump_seed)) +} + +async fn initialize_igp( + banks_client: &mut BanksClient, + payer: &Keypair, + salt: H256, + owner: Option, + beneficiary: Pubkey, +) -> Result<(Pubkey, u8), BanksClientError> { + let program_id = igp_program_id(); + + let (igp_key, igp_bump_seed) = Pubkey::find_program_address(igp_pda_seeds!(salt), &program_id); + + // Accounts: + // 0. [executable] The system program. + // 1. [signer] The payer account. + // 2. [writeable] The IGP account to initialize. + let init_instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::InitIgp(InitIgp { + salt, + owner, + beneficiary, + }), + vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(payer.pubkey(), true), + AccountMeta::new(igp_key, false), + ], + ); + + process_instruction(banks_client, init_instruction, payer, &[payer]).await?; + + Ok((igp_key, igp_bump_seed)) +} + +async fn initialize_overhead_igp( + banks_client: &mut BanksClient, + payer: &Keypair, + salt: H256, + owner: Option, + inner: Pubkey, +) -> Result<(Pubkey, u8), BanksClientError> { + let program_id = igp_program_id(); + + let (overhead_igp_key, overhead_igp_bump_seed) = + Pubkey::find_program_address(overhead_igp_pda_seeds!(salt), &program_id); + + // Accounts: + // 0. [executable] The system program. + // 1. [signer] The payer account. + // 2. [writeable] The Overhead IGP account to initialize. + let init_instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::InitOverheadIgp(InitOverheadIgp { salt, owner, inner }), + vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new_readonly(payer.pubkey(), true), + AccountMeta::new(overhead_igp_key, false), + ], + ); + + process_instruction(banks_client, init_instruction, payer, &[payer]).await?; + + Ok((overhead_igp_key, overhead_igp_bump_seed)) +} + +async fn setup_test_igps( + banks_client: &mut BanksClient, + payer: &Keypair, + domain: u32, + gas_oracle: GasOracle, + gas_overhead: Option, +) -> (Pubkey, Pubkey) { + let program_id = igp_program_id(); + + let salt = H256::random(); + + let (igp_key, _igp_bump_seed) = initialize_igp( + banks_client, + payer, + salt, + Some(payer.pubkey()), + payer.pubkey(), + ) + .await + .unwrap(); + + let instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::SetGasOracleConfigs(vec![GasOracleConfig { + domain, + gas_oracle: Some(gas_oracle), + }]), + vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new(igp_key, false), + AccountMeta::new_readonly(payer.pubkey(), true), + ], + ); + process_instruction(banks_client, instruction, payer, &[payer]) + .await + .unwrap(); + + let (overhead_igp_key, _overhead_igp_bump_seed) = + initialize_overhead_igp(banks_client, payer, salt, Some(payer.pubkey()), igp_key) + .await + .unwrap(); + + if let Some(gas_overhead) = gas_overhead { + let instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::SetDestinationGasOverheads(vec![GasOverheadConfig { + destination_domain: domain, + gas_overhead: Some(gas_overhead), + }]), + vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new(overhead_igp_key, false), + AccountMeta::new_readonly(payer.pubkey(), true), + ], + ); + process_instruction(banks_client, instruction, payer, &[payer]) + .await + .unwrap(); + } + + (igp_key, overhead_igp_key) +} + +// ============ Init ============ + +#[tokio::test] +async fn test_initialize() { + let program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + let (program_data_key, program_data_bump_seed) = + initialize(&mut banks_client, &payer).await.unwrap(); + + // Expect the program data account to be initialized. + let program_data_account = banks_client + .get_account(program_data_key) + .await + .unwrap() + .unwrap(); + assert_eq!(program_data_account.owner, program_id); + + let program_data = ProgramDataAccount::fetch(&mut &program_data_account.data[..]) + .unwrap() + .into_inner(); + assert_eq!( + program_data, + Box::new(ProgramData { + bump_seed: program_data_bump_seed, + payment_count: 0, + }), + ); +} + +#[tokio::test] +async fn test_initialize_errors_if_called_twice() { + let _program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + // Use another payer to force a different tx id, as the blockhash used for the tx is likely to be the same as the first init tx. + let other_payer = new_funded_keypair(&mut banks_client, &payer, 1000000000).await; + + let result = initialize(&mut banks_client, &other_payer).await; + + assert_transaction_error( + result, + TransactionError::InstructionError(0, InstructionError::AccountAlreadyInitialized), + ); +} + +// ============ InitIgp ============ + +#[tokio::test] +async fn test_initialize_igp() { + let program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let salt = H256::random(); + let owner = Some(Pubkey::new_unique()); + let beneficiary = Pubkey::new_unique(); + + let (igp_key, igp_bump_seed) = + initialize_igp(&mut banks_client, &payer, salt, owner, beneficiary) + .await + .unwrap(); + + // Expect the igp account to be initialized. + let igp_account = banks_client.get_account(igp_key).await.unwrap().unwrap(); + assert_eq!(igp_account.owner, program_id); + + let igp = IgpAccount::fetch(&mut &igp_account.data[..]) + .unwrap() + .into_inner(); + assert_eq!( + igp, + Box::new(Igp { + bump_seed: igp_bump_seed, + salt, + owner, + beneficiary, + gas_oracles: HashMap::new(), + }), + ); +} + +#[tokio::test] +async fn test_initialize_igp_errors_if_called_twice() { + let _program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let salt = H256::random(); + let owner = Some(Pubkey::new_unique()); + let beneficiary = Pubkey::new_unique(); + + let (_igp_key, _igp_bump_seed) = + initialize_igp(&mut banks_client, &payer, salt, owner, beneficiary) + .await + .unwrap(); + + // Different owner used to cause the tx ID to be different. + let result = initialize_igp(&mut banks_client, &payer, salt, None, beneficiary).await; + + assert_transaction_error( + result, + TransactionError::InstructionError(0, InstructionError::AccountAlreadyInitialized), + ); +} + +// ============ InitOverheadIgp ============ + +#[tokio::test] +async fn test_initialize_overhead_igp() { + let program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let salt = H256::random(); + let owner = Some(Pubkey::new_unique()); + let inner = Pubkey::new_unique(); + + let (overhead_igp_key, overhead_igp_bump_seed) = + initialize_overhead_igp(&mut banks_client, &payer, salt, owner, inner) + .await + .unwrap(); + + // Expect the overhead igp account to be initialized. + let overhead_igp_account = banks_client + .get_account(overhead_igp_key) + .await + .unwrap() + .unwrap(); + assert_eq!(overhead_igp_account.owner, program_id); + + let overhead_igp = OverheadIgpAccount::fetch(&mut &overhead_igp_account.data[..]) + .unwrap() + .into_inner(); + assert_eq!( + overhead_igp, + Box::new(OverheadIgp { + bump_seed: overhead_igp_bump_seed, + salt, + owner, + inner, + gas_overheads: HashMap::new(), + }), + ); +} + +#[tokio::test] +async fn test_initialize_overhead_igp_errors_if_called_twice() { + let _program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let salt = H256::random(); + let owner = Some(Pubkey::new_unique()); + let inner = Pubkey::new_unique(); + + let (_overhead_igp_key, _overhead_igp_bump_seed) = + initialize_overhead_igp(&mut banks_client, &payer, salt, owner, inner) + .await + .unwrap(); + + // Different owner used to cause the tx ID to be different. + let result = initialize_overhead_igp(&mut banks_client, &payer, salt, None, inner).await; + + assert_transaction_error( + result, + TransactionError::InstructionError(0, InstructionError::AccountAlreadyInitialized), + ); +} + +// ============ SetGasOracleConfigs ============ + +#[tokio::test] +async fn test_set_gas_oracle_configs() { + let program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let salt = H256::random(); + + let (igp_key, _igp_bump_seed) = initialize_igp( + &mut banks_client, + &payer, + salt, + Some(payer.pubkey()), + payer.pubkey(), + ) + .await + .unwrap(); + + let configs = vec![ + GasOracleConfig { + domain: 11, + gas_oracle: Some(GasOracle::RemoteGasData(RemoteGasData { + token_exchange_rate: 112233445566u128, + gas_price: 123456u128, + token_decimals: 18u8, + })), + }, + GasOracleConfig { + domain: 12, + gas_oracle: Some(GasOracle::RemoteGasData(RemoteGasData { + token_exchange_rate: 665544332211u128, + gas_price: 654321u128, + token_decimals: 6u8, + })), + }, + ]; + + // Accounts: + // 0. [executable] The system program. + // 1. [writeable] The IGP. + // 2. [signer] The IGP owner. + let instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::SetGasOracleConfigs(configs.clone()), + vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new(igp_key, false), + AccountMeta::new_readonly(payer.pubkey(), true), + ], + ); + process_instruction(&mut banks_client, instruction, &payer, &[&payer]) + .await + .unwrap(); + + // Expect the gas oracle configs to be set. + let igp_account = banks_client.get_account(igp_key).await.unwrap().unwrap(); + let igp = IgpAccount::fetch(&mut &igp_account.data[..]) + .unwrap() + .into_inner(); + + assert_eq!( + igp.gas_oracles, + configs + .iter() + .cloned() + .map(|c| (c.domain, c.gas_oracle.unwrap())) + .collect(), + ); + + // Remove one of them + let rm_configs = vec![GasOracleConfig { + domain: 12, + gas_oracle: None, + }]; + + let instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::SetGasOracleConfigs(rm_configs.clone()), + vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new(igp_key, false), + AccountMeta::new_readonly(payer.pubkey(), true), + ], + ); + process_instruction(&mut banks_client, instruction, &payer, &[&payer]) + .await + .unwrap(); + + // Make sure the other one is still there + let igp_account = banks_client.get_account(igp_key).await.unwrap().unwrap(); + let igp = IgpAccount::fetch(&mut &igp_account.data[..]) + .unwrap() + .into_inner(); + + let remaining_config = configs[0].clone(); + + assert_eq!( + igp.gas_oracles, + HashMap::from([( + remaining_config.domain, + remaining_config.gas_oracle.unwrap(), + )]), + ); +} + +#[tokio::test] +async fn test_set_gas_oracle_configs_errors_if_owner_not_signer() { + let program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let non_owner = new_funded_keypair(&mut banks_client, &payer, 1000000000).await; + + let salt = H256::random(); + + let (igp_key, _igp_bump_seed) = initialize_igp( + &mut banks_client, + &payer, + salt, + Some(payer.pubkey()), + payer.pubkey(), + ) + .await + .unwrap(); + + let configs = vec![GasOracleConfig { + domain: 11, + gas_oracle: Some(GasOracle::RemoteGasData(RemoteGasData { + token_exchange_rate: 112233445566u128, + gas_price: 123456u128, + token_decimals: 18u8, + })), + }]; + + // Accounts: + // 0. [executable] The system program. + // 1. [writeable] The IGP. + // 2. [signer] The IGP owner. + + // Try with the correct owner passed in, but it's not a signer + let instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::SetGasOracleConfigs(configs.clone()), + vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new(igp_key, false), + AccountMeta::new_readonly(payer.pubkey(), false), + ], + ); + assert_transaction_error( + process_instruction(&mut banks_client, instruction, &non_owner, &[&non_owner]).await, + TransactionError::InstructionError(0, InstructionError::MissingRequiredSignature), + ); + + // Try with the wrong owner passed in, but it's a signer + let instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::SetGasOracleConfigs(configs.clone()), + vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new(igp_key, false), + AccountMeta::new_readonly(non_owner.pubkey(), true), + ], + ); + assert_transaction_error( + process_instruction(&mut banks_client, instruction, &non_owner, &[&non_owner]).await, + TransactionError::InstructionError(0, InstructionError::InvalidArgument), + ); +} + +// ============ SetDestinationGasOverheads ============ + +#[tokio::test] +async fn test_set_destination_gas_overheads() { + let program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let salt = H256::random(); + let inner = Pubkey::new_unique(); + + let (overhead_igp_key, _overhead_igp_bump_seed) = + initialize_overhead_igp(&mut banks_client, &payer, salt, Some(payer.pubkey()), inner) + .await + .unwrap(); + + let configs = vec![ + GasOverheadConfig { + destination_domain: 11, + gas_overhead: Some(112233), + }, + GasOverheadConfig { + destination_domain: 12, + gas_overhead: Some(332211), + }, + ]; + + // Accounts: + // 0. [executable] The system program. + // 1. [writeable] The Overhead IGP. + // 2. [signer] The Overhead IGP owner. + let instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::SetDestinationGasOverheads(configs.clone()), + vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new(overhead_igp_key, false), + AccountMeta::new_readonly(payer.pubkey(), true), + ], + ); + process_instruction(&mut banks_client, instruction, &payer, &[&payer]) + .await + .unwrap(); + + // Expect the configs to be set. + let overhead_igp_account = banks_client + .get_account(overhead_igp_key) + .await + .unwrap() + .unwrap(); + let overhead_igp = OverheadIgpAccount::fetch(&mut &overhead_igp_account.data[..]) + .unwrap() + .into_inner(); + + assert_eq!( + overhead_igp.gas_overheads, + configs + .iter() + .cloned() + .map(|c| (c.destination_domain, c.gas_overhead.unwrap())) + .collect(), + ); + + // Remove one of them + let rm_configs = vec![GasOverheadConfig { + destination_domain: 12, + gas_overhead: None, + }]; + + let instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::SetDestinationGasOverheads(rm_configs.clone()), + vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new(overhead_igp_key, false), + AccountMeta::new_readonly(payer.pubkey(), true), + ], + ); + process_instruction(&mut banks_client, instruction, &payer, &[&payer]) + .await + .unwrap(); + + // Make sure the other one is still there + let overhead_igp_account = banks_client + .get_account(overhead_igp_key) + .await + .unwrap() + .unwrap(); + let overhead_igp = OverheadIgpAccount::fetch(&mut &overhead_igp_account.data[..]) + .unwrap() + .into_inner(); + + let remaining_config = configs[0].clone(); + + assert_eq!( + overhead_igp.gas_overheads, + HashMap::from([( + remaining_config.destination_domain, + remaining_config.gas_overhead.unwrap(), + )]), + ); +} + +#[tokio::test] +async fn test_set_destination_gas_overheads_errors_if_owner_not_signer() { + let program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let non_owner = new_funded_keypair(&mut banks_client, &payer, 1000000000).await; + + let salt = H256::random(); + let inner = Pubkey::new_unique(); + + let (overhead_igp_key, _overhead_igp_bump_seed) = + initialize_overhead_igp(&mut banks_client, &payer, salt, Some(payer.pubkey()), inner) + .await + .unwrap(); + + let configs = vec![GasOverheadConfig { + destination_domain: 11, + gas_overhead: Some(112233), + }]; + + // Accounts: + // 0. [executable] The system program. + // 1. [writeable] The Overhead IGP. + // 2. [signer] The Overhead IGP owner. + + // Try with the correct owner passed in, but it's not a signer + let instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::SetDestinationGasOverheads(configs.clone()), + vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new(overhead_igp_key, false), + AccountMeta::new_readonly(payer.pubkey(), false), + ], + ); + assert_transaction_error( + process_instruction(&mut banks_client, instruction, &non_owner, &[&non_owner]).await, + TransactionError::InstructionError(0, InstructionError::MissingRequiredSignature), + ); + + // Try with the wrong owner passed in, but it's a signer + let instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::SetDestinationGasOverheads(configs.clone()), + vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new(overhead_igp_key, false), + AccountMeta::new_readonly(non_owner.pubkey(), true), + ], + ); + assert_transaction_error( + process_instruction(&mut banks_client, instruction, &non_owner, &[&non_owner]).await, + TransactionError::InstructionError(0, InstructionError::InvalidArgument), + ); +} + +// ============ QuoteGasPayment ============ + +async fn quote_gas_payment( + banks_client: &mut BanksClient, + payer: &Keypair, + destination_domain: u32, + gas_amount: u64, + igp_key: Pubkey, + overhead_igp_key: Option, +) -> Result { + let mut accounts = vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new(igp_key, false), + ]; + if let Some(overhead_igp_key) = overhead_igp_key { + accounts.push(AccountMeta::new_readonly(overhead_igp_key, false)); + } + + let instruction = Instruction::new_with_borsh( + igp_program_id(), + &IgpInstruction::QuoteGasPayment(QuoteGasPayment { + destination_domain, + gas_amount, + }), + accounts, + ); + + simulate_instruction::>(banks_client, payer, instruction) + .await + .map(|r| r.unwrap().return_data) +} + +async fn run_quote_gas_payment_tests(gas_amount: u64, overhead_gas_amount: Option) { + assert_eq!( + gas_amount + overhead_gas_amount.unwrap_or_default(), + TEST_GAS_AMOUNT + ); + + let _program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + // Testing when exchange rates are relatively close. + // The base asset has 9 decimals, there's a 1:1 exchange rate, + // and the remote asset also has 9 decimals. + let (igp_key, _overhead_igp_key) = setup_test_igps( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + GasOracle::RemoteGasData(RemoteGasData { + // 0.2 exchange rate (remote token less valuable) + token_exchange_rate: (TOKEN_EXCHANGE_RATE_SCALE / 5), + gas_price: 150u64.into(), // 150 gas price + token_decimals: LOCAL_DECIMALS, // same decimals as local + }), + Some(TEST_GAS_OVERHEAD_AMOUNT), + ) + .await; + + assert_eq!( + quote_gas_payment( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + TEST_GAS_AMOUNT, + igp_key, + None, + ) + .await + .unwrap(), + // 300,000 destination gas + // 150 gas price + // 300,000 * 150 = 45000000 (0.045 remote tokens w/ 9 decimals) + // Using the 0.2 token exchange rate, meaning the local native token + // is 5x more valuable than the remote token: + // 45000000 * 0.2 = 9000000 (0.009 local tokens w/ 9 decimals) + 9000000u64, + ); + + // Testing when the remote token is much more valuable, has higher decimals, & there's a super high gas price + let (igp_key, _overhead_igp_key) = setup_test_igps( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + GasOracle::RemoteGasData(RemoteGasData { + // remote token 5000x more valuable + token_exchange_rate: (5000 * TOKEN_EXCHANGE_RATE_SCALE), + gas_price: 1500000000000u64.into(), // 150 gwei gas price + token_decimals: 18, // remote has 18 decimals + }), + Some(TEST_GAS_OVERHEAD_AMOUNT), + ) + .await; + + assert_eq!( + quote_gas_payment( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + TEST_GAS_AMOUNT, + igp_key, + None, + ) + .await + .unwrap(), + // 300,000 destination gas + // 1500 gwei = 1500000000000 wei + // 300,000 * 1500000000000 = 450000000000000000 (0.45 remote tokens w/ 18 decimals) + // Using the 5000 * 1e19 token exchange rate, meaning the remote native token + // is 5000x more valuable than the local token, and adjusting for decimals: + // 450000000000000000 * 5000 * 1e-9 = 2250000000000 (2250 local tokens w/ 9 decimals) + 2250000000000u64, + ); + + // Testing when the remote token is much less valuable & there's a low gas price, but has 18 decimals + let (igp_key, _overhead_igp_key) = setup_test_igps( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + GasOracle::RemoteGasData(RemoteGasData { + // remote token 0.04x the price + token_exchange_rate: (4 * TOKEN_EXCHANGE_RATE_SCALE / 100), + gas_price: 100000000u64.into(), // 0.1 gwei gas price + token_decimals: 18, // remote has 18 decimals + }), + Some(TEST_GAS_OVERHEAD_AMOUNT), + ) + .await; + + assert_eq!( + quote_gas_payment( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + TEST_GAS_AMOUNT, + igp_key, + None, + ) + .await + .unwrap(), + // 300,000 destination gas + // 0.1 gwei = 100000000 wei + // 300,000 * 100000000 = 30000000000000 (0.00003 remote tokens w/ 18 decimals) + // Using the 0.04 * 1e19 token exchange rate, meaning the remote native token + // is 0.04x the price of the local token, and adjusting for decimals: + // 30000000000000 * 0.04 * 1e-9 = 1200 (0.0000012 local tokens w/ 9 decimals) + 1200u64, + ); + + // Testing when the remote token is much less valuable & there's a low gas price, but has 4 decimals + let (igp_key, _overhead_igp_key) = setup_test_igps( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + GasOracle::RemoteGasData(RemoteGasData { + // remote token 10x the price + token_exchange_rate: (10 * TOKEN_EXCHANGE_RATE_SCALE), + gas_price: 10u64.into(), // 10 gas price + token_decimals: 4u8, // remote has 4 decimals + }), + Some(TEST_GAS_OVERHEAD_AMOUNT), + ) + .await; + + assert_eq!( + quote_gas_payment( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + TEST_GAS_AMOUNT, + igp_key, + None, + ) + .await + .unwrap(), + // 300,000 destination gas + // 10 gas price + // 300,000 * 10 = 3000000 (300.0000 remote tokens w/ 4 decimals) + // Using the 10 * 1e19 token exchange rate, meaning the remote native token + // is 10x the price of the local token, and adjusting for decimals: + // 3000000 * 10 * 1e5 = 3000000000000 (3000 local tokens w/ 9 decimals) + 3000000000000u64, + ); +} + +#[tokio::test] +async fn test_quote_gas_payment_no_overhead() { + run_quote_gas_payment_tests(TEST_GAS_AMOUNT, None).await; +} + +#[tokio::test] +async fn test_quote_gas_payment_with_overhead() { + run_quote_gas_payment_tests( + TEST_GAS_AMOUNT - TEST_GAS_OVERHEAD_AMOUNT, + Some(TEST_GAS_OVERHEAD_AMOUNT), + ) + .await; +} + +#[tokio::test] +async fn test_quote_gas_payment_errors_if_no_gas_oracle() { + let _program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + let (igp_key, _overhead_igp_key) = setup_test_igps( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + GasOracle::RemoteGasData(RemoteGasData { + token_exchange_rate: TOKEN_EXCHANGE_RATE_SCALE, + gas_price: 1u128, + token_decimals: LOCAL_DECIMALS, + }), + None, + ) + .await; + + assert_transaction_error( + quote_gas_payment( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN + 1, + TEST_GAS_AMOUNT, + igp_key, + None, + ) + .await, + TransactionError::InstructionError( + 0, + InstructionError::Custom(IgpError::NoGasOracleSetForDestinationDomain as u32), + ), + ); +} + +// ============ PayForGas ============ + +async fn pay_for_gas( + banks_client: &mut BanksClient, + payer: &Keypair, + igp: Pubkey, + overhead_igp: Option, + destination_domain: u32, + gas_amount: u64, + message_id: H256, +) -> Result<(Pubkey, Keypair, Signature), BanksClientError> { + let program_id = igp_program_id(); + let unique_payment_account = Keypair::new(); + let (igp_program_data_key, _) = + Pubkey::find_program_address(igp_program_data_pda_seeds!(), &program_id); + let (gas_payment_pda_key, _) = Pubkey::find_program_address( + igp_gas_payment_pda_seeds!(unique_payment_account.pubkey()), + &program_id, + ); + + // 0. [executable] The system program. + // 1. [signer] The payer. + // 2. [writeable] The IGP program data. + // 3. [writeable] The IGP account. + // 4. [signer] Unique gas payment account. + // 5. [writeable] Gas payment PDA. + // 6. [] Overhead IGP account (optional). + let mut accounts = vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new(payer.pubkey(), true), + AccountMeta::new(igp_program_data_key, false), + AccountMeta::new(igp, false), + AccountMeta::new_readonly(unique_payment_account.pubkey(), true), + AccountMeta::new(gas_payment_pda_key, false), + ]; + if let Some(overhead_igp) = overhead_igp { + accounts.push(AccountMeta::new_readonly(overhead_igp, false)); + } + + let instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::PayForGas(PayForGas { + destination_domain, + gas_amount, + message_id, + }), + accounts, + ); + + let tx_signature = process_instruction( + banks_client, + instruction, + payer, + &[payer, &unique_payment_account], + ) + .await?; + + Ok((gas_payment_pda_key, unique_payment_account, tx_signature)) +} + +#[allow(clippy::too_many_arguments)] +async fn assert_gas_payment( + banks_client: &mut BanksClient, + igp_key: Pubkey, + payment_tx_signature: Signature, + _payment_unique_account_pubkey: Pubkey, + gas_payment_account_key: Pubkey, + destination_domain: u32, + gas_amount: u64, + message_id: H256, + sequence_number: u64, +) { + // Get the slot of the tx + let tx_status = banks_client + .get_transaction_status(payment_tx_signature) + .await + .unwrap() + .unwrap(); + let slot = tx_status.slot; + + // Get the gas payment account + let gas_payment_account = banks_client + .get_account(gas_payment_account_key) + .await + .unwrap() + .unwrap(); + let gas_payment = GasPaymentAccount::fetch(&mut &gas_payment_account.data[..]) + .unwrap() + .into_inner(); + assert_eq!( + *gas_payment, + GasPaymentData { + sequence_number, + igp: igp_key, + destination_domain, + message_id, + gas_amount, + slot, + } + .into(), + ); +} + +async fn run_pay_for_gas_tests(gas_amount: u64, overhead_gas_amount: Option) { + let _program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + let message_id = H256::random(); + + initialize(&mut banks_client, &payer).await.unwrap(); + + let (igp_key, overhead_igp_key) = setup_test_igps( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + GasOracle::RemoteGasData(RemoteGasData { + token_exchange_rate: TOKEN_EXCHANGE_RATE_SCALE, + gas_price: 1u128, + token_decimals: LOCAL_DECIMALS, + }), + overhead_gas_amount, + ) + .await; + + let quote = quote_gas_payment( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + gas_amount, + igp_key, + // Only pass in the overhead igp key if there's an overhead amount + overhead_gas_amount.map(|_| overhead_igp_key), + ) + .await + .unwrap(); + + let igp_balance_before = banks_client.get_balance(igp_key).await.unwrap(); + + let (gas_payment_pda_key, unique_payment_account, payment_tx_signature) = pay_for_gas( + &mut banks_client, + &payer, + igp_key, + // Only pass in the overhead igp key if there's an overhead amount + overhead_gas_amount.map(|_| overhead_igp_key), + TEST_DESTINATION_DOMAIN, + gas_amount, + message_id, + ) + .await + .unwrap(); + + let igp_balance_after = banks_client.get_balance(igp_key).await.unwrap(); + + assert_eq!(igp_balance_after - igp_balance_before, quote,); + assert!(quote > 0); + + assert_gas_payment( + &mut banks_client, + igp_key, + payment_tx_signature, + unique_payment_account.pubkey(), + gas_payment_pda_key, + TEST_DESTINATION_DOMAIN, + gas_amount + overhead_gas_amount.unwrap_or_default(), + message_id, + 0, + ) + .await; + + // Send another payment to confirm the sequence number is incremented + let (gas_payment_pda_key, unique_payment_account, payment_tx_signature) = pay_for_gas( + &mut banks_client, + &payer, + igp_key, + // Only pass in the overhead igp key if there's an overhead amount + overhead_gas_amount.map(|_| overhead_igp_key), + TEST_DESTINATION_DOMAIN, + gas_amount, + message_id, + ) + .await + .unwrap(); + + assert_gas_payment( + &mut banks_client, + igp_key, + payment_tx_signature, + unique_payment_account.pubkey(), + gas_payment_pda_key, + TEST_DESTINATION_DOMAIN, + gas_amount + overhead_gas_amount.unwrap_or_default(), + message_id, + 1, + ) + .await; +} + +#[tokio::test] +async fn test_pay_for_gas_no_overhead() { + run_pay_for_gas_tests(TEST_GAS_AMOUNT, None).await; +} + +#[tokio::test] +async fn test_pay_for_gas_with_overhead() { + run_pay_for_gas_tests(TEST_GAS_AMOUNT, Some(TEST_GAS_OVERHEAD_AMOUNT)).await; +} + +#[tokio::test] +async fn test_pay_for_gas_errors_if_payer_balance_is_insufficient() { + let _program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + let balance = 1000000000; + + let low_balance_payer = new_funded_keypair(&mut banks_client, &payer, balance).await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let (igp_key, _) = setup_test_igps( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + GasOracle::RemoteGasData(RemoteGasData { + token_exchange_rate: TOKEN_EXCHANGE_RATE_SCALE, + gas_price: 1000000000u128, + token_decimals: LOCAL_DECIMALS, + }), + None, + ) + .await; + + let quote = quote_gas_payment( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + TEST_GAS_AMOUNT, + igp_key, + None, + ) + .await + .unwrap(); + + assert!(quote > balance); + + assert_transaction_error( + pay_for_gas( + &mut banks_client, + &low_balance_payer, + igp_key, + None, + TEST_DESTINATION_DOMAIN, + TEST_GAS_AMOUNT, + H256::random(), + ) + .await, + TransactionError::InstructionError( + 0, + // Corresponds to `SystemError::ResultWithNegativeLamports` in the system program. + // See https://github.com/solana-labs/solana/blob/cd39a6afd35288a0c2d3b2cf8995b29790889e69/sdk/program/src/system_instruction.rs#L61 + InstructionError::Custom(1), + ), + ); +} + +#[tokio::test] +async fn test_pay_for_gas_errors_if_no_gas_oracle() { + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let (igp_key, _) = setup_test_igps( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + GasOracle::RemoteGasData(RemoteGasData { + token_exchange_rate: TOKEN_EXCHANGE_RATE_SCALE, + gas_price: 1u128, + token_decimals: LOCAL_DECIMALS, + }), + None, + ) + .await; + + assert_transaction_error( + pay_for_gas( + &mut banks_client, + &payer, + igp_key, + None, + TEST_DESTINATION_DOMAIN + 1, + TEST_GAS_AMOUNT, + H256::random(), + ) + .await, + TransactionError::InstructionError( + 0, + InstructionError::Custom(IgpError::NoGasOracleSetForDestinationDomain as u32), + ), + ); +} + +// ============ Claim ============ + +#[tokio::test] +async fn test_claim() { + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let (igp_key, _) = setup_test_igps( + &mut banks_client, + &payer, + TEST_DESTINATION_DOMAIN, + GasOracle::RemoteGasData(RemoteGasData { + token_exchange_rate: TOKEN_EXCHANGE_RATE_SCALE, + gas_price: 1u128, + token_decimals: LOCAL_DECIMALS, + }), + None, + ) + .await; + + let claim_amount = 1234567; + // Transfer the claim amount to the IGP account + transfer_lamports(&mut banks_client, &payer, &igp_key, claim_amount).await; + + let non_beneficiary = new_funded_keypair(&mut banks_client, &payer, 1000000000).await; + + let beneficiary_balance_before = banks_client.get_balance(payer.pubkey()).await.unwrap(); + + // Accounts: + // 0. [executable] The system program. + // 1. [writeable] The IGP. + // 2. [writeable] The IGP beneficiary. + process_instruction( + &mut banks_client, + Instruction::new_with_borsh( + igp_program_id(), + &IgpInstruction::Claim, + vec![ + AccountMeta::new_readonly(system_program::id(), false), + AccountMeta::new(igp_key, false), + AccountMeta::new(payer.pubkey(), false), + ], + ), + &non_beneficiary, + &[&non_beneficiary], + ) + .await + .unwrap(); + + let beneficiary_balance_after = banks_client.get_balance(payer.pubkey()).await.unwrap(); + assert_eq!( + beneficiary_balance_after - beneficiary_balance_before, + claim_amount, + ); + + // Make sure the IGP account is still rent exempt + let igp_account = banks_client.get_account(igp_key).await.unwrap().unwrap(); + let rent_exempt_balance = Rent::default().minimum_balance(igp_account.data.len()); + assert_eq!(igp_account.lamports, rent_exempt_balance); +} + +// ============ SetIgpBeneficiary ============ + +#[tokio::test] +async fn test_set_igp_beneficiary() { + let program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let salt = H256::random(); + + let (igp_key, _igp_bump_seed) = initialize_igp( + &mut banks_client, + &payer, + salt, + Some(payer.pubkey()), + payer.pubkey(), + ) + .await + .unwrap(); + + let new_beneficiary = Pubkey::new_unique(); + + // Accounts: + // 0. [] The IGP. + // 1. [signer] The owner of the IGP account. + let instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::SetIgpBeneficiary(new_beneficiary), + vec![ + AccountMeta::new(igp_key, false), + AccountMeta::new_readonly(payer.pubkey(), true), + ], + ); + process_instruction(&mut banks_client, instruction, &payer, &[&payer]) + .await + .unwrap(); + + // Expect the beneficiary to be set. + let igp_account = banks_client.get_account(igp_key).await.unwrap().unwrap(); + let igp = IgpAccount::fetch(&mut &igp_account.data[..]) + .unwrap() + .into_inner(); + + assert_eq!(igp.beneficiary, new_beneficiary,); +} + +#[tokio::test] +async fn test_set_igp_beneficiary_errors_if_owner_not_signer() { + let program_id = igp_program_id(); + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let non_owner = new_funded_keypair(&mut banks_client, &payer, 1000000000).await; + let new_beneficiary = Pubkey::new_unique(); + + let salt = H256::random(); + + let (igp_key, _igp_bump_seed) = initialize_igp( + &mut banks_client, + &payer, + salt, + Some(payer.pubkey()), + payer.pubkey(), + ) + .await + .unwrap(); + + // Accounts: + // 0. [] The IGP. + // 1. [signer] The owner of the IGP account. + + // Try with the right owner passed in, but it's not a signer + let instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::SetIgpBeneficiary(new_beneficiary), + vec![ + AccountMeta::new(igp_key, false), + AccountMeta::new_readonly(payer.pubkey(), false), + ], + ); + assert_transaction_error( + process_instruction(&mut banks_client, instruction, &non_owner, &[&non_owner]).await, + TransactionError::InstructionError(0, InstructionError::MissingRequiredSignature), + ); + + // Try with the wrong owner passed in, but it's a signer + let instruction = Instruction::new_with_borsh( + program_id, + &IgpInstruction::SetIgpBeneficiary(new_beneficiary), + vec![ + AccountMeta::new(igp_key, false), + AccountMeta::new_readonly(non_owner.pubkey(), true), + ], + ); + assert_transaction_error( + process_instruction(&mut banks_client, instruction, &non_owner, &[&non_owner]).await, + TransactionError::InstructionError(0, InstructionError::InvalidArgument), + ); +} + +// ============ TransferIgpOwnership & TransferOverheadIgpOwnership ============ + +async fn run_transfer_ownership_tests( + banks_client: &mut BanksClient, + payer: &Keypair, + account_key: Pubkey, + transfer_ownership_instruction: impl Fn(Option) -> IgpInstruction, +) { + let program_id = igp_program_id(); + + let new_owner = new_funded_keypair(banks_client, payer, 1000000000).await; + + // Accounts: + // 0. [] The IGP or Overhead IGP. + // 1. [signer] The owner of the account. + let instruction = Instruction::new_with_borsh( + program_id, + &transfer_ownership_instruction(Some(new_owner.pubkey())), + vec![ + AccountMeta::new(account_key, false), + AccountMeta::new_readonly(payer.pubkey(), true), + ], + ); + process_instruction(banks_client, instruction.clone(), payer, &[payer]) + .await + .unwrap(); + + // Expect the owner to be set. + let account = banks_client + .get_account(account_key) + .await + .unwrap() + .unwrap(); + let account_data = AccountData::::fetch(&mut &account.data[..]) + .unwrap() + .into_inner(); + + assert_eq!(account_data.owner(), Some(&new_owner.pubkey()),); + + // Try to transfer ownership again, but now the payer isn't the owner anymore + + // Try with the old (now incorrect) owner passed in and as a signer. + // Use a random new owner to ensure a different tx signature is used. + let instruction = Instruction::new_with_borsh( + program_id, + &transfer_ownership_instruction(Some(Pubkey::new_unique())), + vec![ + AccountMeta::new(account_key, false), + AccountMeta::new_readonly(payer.pubkey(), true), + ], + ); + assert_transaction_error( + process_instruction(banks_client, instruction, payer, &[payer]).await, + TransactionError::InstructionError(0, InstructionError::InvalidArgument), + ); + + // Try with the new owner passed in, but it's not a signer + let instruction = Instruction::new_with_borsh( + program_id, + &transfer_ownership_instruction(Some(new_owner.pubkey())), + vec![ + AccountMeta::new(account_key, false), + AccountMeta::new_readonly(new_owner.pubkey(), false), + ], + ); + assert_transaction_error( + process_instruction(banks_client, instruction, payer, &[payer]).await, + TransactionError::InstructionError(0, InstructionError::MissingRequiredSignature), + ); + + // Set the owner to None, and the new_owner should still not be able to transfer ownership + let instruction = Instruction::new_with_borsh( + program_id, + &transfer_ownership_instruction(None), + vec![ + AccountMeta::new(account_key, false), + AccountMeta::new_readonly(new_owner.pubkey(), true), + ], + ); + process_instruction(banks_client, instruction.clone(), &new_owner, &[&new_owner]) + .await + .unwrap(); + + // Should not be able to transfer ownership anymore. + // Try setting a different owner to ensure a different tx signature is used. + let instruction = Instruction::new_with_borsh( + program_id, + &transfer_ownership_instruction(Some(Pubkey::new_unique())), + vec![ + AccountMeta::new(account_key, false), + AccountMeta::new_readonly(new_owner.pubkey(), true), + ], + ); + assert_transaction_error( + process_instruction(banks_client, instruction, &new_owner, &[&new_owner]).await, + TransactionError::InstructionError(0, InstructionError::InvalidArgument), + ); +} + +#[tokio::test] +async fn test_transfer_igp_ownership() { + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let salt = H256::random(); + + let (igp_key, _igp_bump_seed) = initialize_igp( + &mut banks_client, + &payer, + salt, + Some(payer.pubkey()), + payer.pubkey(), + ) + .await + .unwrap(); + + run_transfer_ownership_tests::(&mut banks_client, &payer, igp_key, |owner| { + IgpInstruction::TransferIgpOwnership(owner) + }) + .await; +} + +#[tokio::test] +async fn test_transfer_overhead_igp_ownership() { + let (mut banks_client, payer) = setup_client().await; + + initialize(&mut banks_client, &payer).await.unwrap(); + + let salt = H256::random(); + + let (overhead_igp_key, _igp_bump_seed) = initialize_overhead_igp( + &mut banks_client, + &payer, + salt, + Some(payer.pubkey()), + Pubkey::new_unique(), + ) + .await + .unwrap(); + + run_transfer_ownership_tests::( + &mut banks_client, + &payer, + overhead_igp_key, + IgpInstruction::TransferOverheadIgpOwnership, + ) + .await; +} diff --git a/rust/sealevel/programs/ism/multisig-ism-message-id/Cargo.toml b/rust/sealevel/programs/ism/multisig-ism-message-id/Cargo.toml index 5a7af82e06..d7580309d1 100644 --- a/rust/sealevel/programs/ism/multisig-ism-message-id/Cargo.toml +++ b/rust/sealevel/programs/ism/multisig-ism-message-id/Cargo.toml @@ -26,7 +26,7 @@ serializable-account-meta = { path = "../../../libraries/serializable-account-me [dev-dependencies] hyperlane-sealevel-multisig-ism-message-id = { path = "../multisig-ism-message-id" } - +hyperlane-test-utils = { path = "../../../libraries/test-utils" } solana-program-test.workspace = true solana-sdk.workspace = true hex.workspace = true diff --git a/rust/sealevel/programs/ism/multisig-ism-message-id/tests/functional.rs b/rust/sealevel/programs/ism/multisig-ism-message-id/tests/functional.rs index f062e3e392..a66e13529b 100644 --- a/rust/sealevel/programs/ism/multisig-ism-message-id/tests/functional.rs +++ b/rust/sealevel/programs/ism/multisig-ism-message-id/tests/functional.rs @@ -24,6 +24,7 @@ use hyperlane_sealevel_multisig_ism_message_id::{ metadata::MultisigIsmMessageIdMetadata, processor::process_instruction, }; +use hyperlane_test_utils::assert_transaction_error; use multisig_ism::interface::{ MultisigIsmInstruction, VALIDATORS_AND_THRESHOLD_ACCOUNT_METAS_PDA_SEEDS, }; @@ -184,18 +185,13 @@ async fn test_initialize_errors_if_called_twice() { let new_payer = new_funded_keypair(&mut banks_client, &payer, 1000000).await; let result = initialize(program_id, &mut banks_client, &new_payer, recent_blockhash).await; - // BanksClientError doesn't implement Eq, but TransactionError does - if let BanksClientError::TransactionError(tx_err) = result.err().unwrap() { - assert_eq!( - tx_err, - TransactionError::InstructionError( - 0, - InstructionError::Custom(MultisigIsmError::AlreadyInitialized as u32) - ) - ); - } else { - panic!("expected TransactionError"); - } + assert_transaction_error( + result, + TransactionError::InstructionError( + 0, + InstructionError::Custom(MultisigIsmError::AlreadyInitialized as u32), + ), + ); } #[tokio::test] diff --git a/rust/sealevel/programs/validator-announce/Cargo.toml b/rust/sealevel/programs/validator-announce/Cargo.toml index 6372f5f0a8..de82b819b8 100644 --- a/rust/sealevel/programs/validator-announce/Cargo.toml +++ b/rust/sealevel/programs/validator-announce/Cargo.toml @@ -23,6 +23,7 @@ serializable-account-meta = { path = "../../libraries/serializable-account-meta" hex.workspace = true solana-program-test = "1.14.13" solana-sdk.workspace = true +hyperlane-test-utils ={ path = "../../libraries/test-utils" } [lib] crate-type = ["cdylib", "lib"] diff --git a/rust/sealevel/programs/validator-announce/tests/functional.rs b/rust/sealevel/programs/validator-announce/tests/functional.rs index 003c6445b3..68ee589f9d 100644 --- a/rust/sealevel/programs/validator-announce/tests/functional.rs +++ b/rust/sealevel/programs/validator-announce/tests/functional.rs @@ -12,10 +12,8 @@ use solana_program::{ }; use solana_program_test::*; use solana_sdk::{ - instruction::InstructionError, - signature::Signer, - signer::keypair::Keypair, - transaction::{Transaction, TransactionError}, + instruction::InstructionError, signature::Signer, signer::keypair::Keypair, + transaction::TransactionError, }; use hyperlane_sealevel_validator_announce::{ @@ -26,10 +24,11 @@ use hyperlane_sealevel_validator_announce::{ instruction::{ AnnounceInstruction, InitInstruction, Instruction as ValidatorAnnounceInstruction, }, - processor::process_instruction, + processor::process_instruction as validator_announce_process_instruction, replay_protection_pda_seeds, validator_announce_pda_seeds, validator_storage_locations_pda_seeds, }; +use hyperlane_test_utils::{assert_transaction_error, process_instruction}; // The Ethereum mailbox & domain chosen for easy testing const TEST_MAILBOX: &str = "00000000000000000000000035231d4c2d8b8adcb5617a638a0c4548684c7c70"; @@ -78,23 +77,6 @@ fn get_test_announcements() -> Vec<(Announcement, Vec)> { vec![(announcement0, signature0), (announcement1, signature1)] } -async fn send_transaction_with_instruction( - banks_client: &mut BanksClient, - payer: &Keypair, - instruction: Instruction, -) -> Result<(), BanksClientError> { - let recent_blockhash = banks_client.get_latest_blockhash().await.unwrap(); - let transaction = Transaction::new_signed_with_payer( - &[instruction], - Some(&payer.pubkey()), - &[payer], - recent_blockhash, - ); - banks_client.process_transaction(transaction).await?; - - Ok(()) -} - async fn initialize( banks_client: &mut BanksClient, payer: &Keypair, @@ -122,7 +104,7 @@ async fn initialize( ], ); - send_transaction_with_instruction(banks_client, payer, init_instruction).await?; + process_instruction(banks_client, init_instruction, payer, &[payer]).await?; Ok((validator_announce_key, validator_announce_bump_seed)) } @@ -133,7 +115,7 @@ async fn test_initialize() { let (mut banks_client, payer, _recent_blockhash) = ProgramTest::new( "hyperlane_sealevel_validator_announce", program_id, - processor!(process_instruction), + processor!(validator_announce_process_instruction), ) .start() .await; @@ -172,7 +154,7 @@ async fn test_initialize_errors_if_called_twice() { let (mut banks_client, payer, _recent_blockhash) = ProgramTest::new( "hyperlane_sealevel_validator_announce", program_id, - processor!(process_instruction), + processor!(validator_announce_process_instruction), ) .start() .await; @@ -187,15 +169,10 @@ async fn test_initialize_errors_if_called_twice() { // As a workaround, use a different mailbox let init_result = initialize(&mut banks_client, &payer, Pubkey::new_unique()).await; - // BanksClientError doesn't implement Eq, but TransactionError does - if let BanksClientError::TransactionError(tx_err) = init_result.err().unwrap() { - assert_eq!( - tx_err, - TransactionError::InstructionError(0, InstructionError::AccountAlreadyInitialized,) - ); - } else { - panic!("expected TransactionError"); - } + assert_transaction_error( + init_result, + TransactionError::InstructionError(0, InstructionError::AccountAlreadyInitialized), + ); } async fn announce( @@ -234,7 +211,7 @@ async fn announce( ], ); - send_transaction_with_instruction(banks_client, payer, announce_instruction).await?; + process_instruction(banks_client, announce_instruction, payer, &[payer]).await?; Ok(( validator_storage_locations_key, @@ -303,7 +280,7 @@ async fn test_announce() { let (mut banks_client, payer, _recent_blockhash) = ProgramTest::new( "hyperlane_sealevel_validator_announce", program_id, - processor!(process_instruction), + processor!(validator_announce_process_instruction), ) .start() .await; @@ -359,15 +336,10 @@ async fn test_announce() { announce_instruction.clone(), ) .await; - // BanksClientError doesn't implement Eq, but TransactionError does - if let BanksClientError::TransactionError(tx_err) = announce_result.err().unwrap() { - assert_eq!( - tx_err, - TransactionError::InstructionError(0, InstructionError::AccountAlreadyInitialized,) - ); - } else { - panic!("expected TransactionError"); - } + assert_transaction_error( + announce_result, + TransactionError::InstructionError(0, InstructionError::AccountAlreadyInitialized), + ); // And then announce the second storage location, which we expect to be successful let (announcement, signature) = test_announcements[1].clone(); From 4892c73fa79f342c209043a45109a075707f64ec Mon Sep 17 00:00:00 2001 From: Trevor Porter Date: Thu, 27 Jul 2023 10:31:26 +0100 Subject: [PATCH 2/5] nits --- rust/sealevel/libraries/account-utils/src/discriminator.rs | 2 ++ rust/sealevel/libraries/account-utils/src/lib.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/rust/sealevel/libraries/account-utils/src/discriminator.rs b/rust/sealevel/libraries/account-utils/src/discriminator.rs index a9c9836383..8d7e4f7b22 100644 --- a/rust/sealevel/libraries/account-utils/src/discriminator.rs +++ b/rust/sealevel/libraries/account-utils/src/discriminator.rs @@ -78,6 +78,7 @@ pub trait DiscriminatorData: Sized { const DISCRIMINATOR_SLICE: &'static [u8] = &Self::DISCRIMINATOR; } +/// Encodes the given data with a discriminator prefix. pub trait DiscriminatorEncode: DiscriminatorData + borsh::BorshSerialize { fn encode(self) -> Result, ProgramError> { let mut buf = vec![]; @@ -94,6 +95,7 @@ pub trait DiscriminatorEncode: DiscriminatorData + borsh::BorshSerialize { // Auto-implement impl DiscriminatorEncode for T where T: DiscriminatorData + borsh::BorshSerialize {} +/// Decodes the given data with a discriminator prefix. pub trait DiscriminatorDecode: DiscriminatorData + borsh::BorshDeserialize { fn decode(data: &[u8]) -> Result { let (discriminator, rest) = data.split_at(Discriminator::LENGTH); diff --git a/rust/sealevel/libraries/account-utils/src/lib.rs b/rust/sealevel/libraries/account-utils/src/lib.rs index 3fb53213da..6301c66f43 100644 --- a/rust/sealevel/libraries/account-utils/src/lib.rs +++ b/rust/sealevel/libraries/account-utils/src/lib.rs @@ -152,6 +152,8 @@ impl AccountData where T: Data + SizedData, { + /// Stores the account data in the given account, reallocing the account + /// if necessary, and ensuring it is rent exempt. pub fn store_with_rent_exempt_realloc<'a, 'b>( &self, account_info: &'a AccountInfo<'b>, From 4ea5365389e826d548bfd4e82b6d714cdbc7c6d1 Mon Sep 17 00:00:00 2001 From: Trevor Porter Date: Thu, 27 Jul 2023 10:33:59 +0100 Subject: [PATCH 3/5] nits --- .../programs/interchain-gas-paymaster/src/accounts.rs | 2 +- .../programs/interchain-gas-paymaster/tests/functional.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rust/sealevel/programs/interchain-gas-paymaster/src/accounts.rs b/rust/sealevel/programs/interchain-gas-paymaster/src/accounts.rs index aaca278ec8..e3a2ec89b3 100644 --- a/rust/sealevel/programs/interchain-gas-paymaster/src/accounts.rs +++ b/rust/sealevel/programs/interchain-gas-paymaster/src/accounts.rs @@ -13,7 +13,7 @@ use crate::error::Error; /// The scale for token exchange rates, i.e. a token exchange rate of 1.0 is /// represented as 10^19. -pub const TOKEN_EXCHANGE_RATE_SCALE: u64 = 10u64.pow(19); +pub const TOKEN_EXCHANGE_RATE_SCALE: u128 = 10u128.pow(19); /// The number of decimals for the native SOL token. pub const SOL_DECIMALS: u8 = 9; diff --git a/rust/sealevel/programs/interchain-gas-paymaster/tests/functional.rs b/rust/sealevel/programs/interchain-gas-paymaster/tests/functional.rs index 7f911eea5e..d4a3eacdc8 100644 --- a/rust/sealevel/programs/interchain-gas-paymaster/tests/functional.rs +++ b/rust/sealevel/programs/interchain-gas-paymaster/tests/functional.rs @@ -26,7 +26,8 @@ use account_utils::{AccountData, Data}; use hyperlane_sealevel_igp::{ accounts::{ GasOracle, GasPaymentAccount, GasPaymentData, Igp, IgpAccount, OverheadIgp, - OverheadIgpAccount, ProgramData, ProgramDataAccount, RemoteGasData, + OverheadIgpAccount, ProgramData, ProgramDataAccount, RemoteGasData, SOL_DECIMALS, + TOKEN_EXCHANGE_RATE_SCALE, }, error::Error as IgpError, igp_gas_payment_pda_seeds, igp_pda_seeds, igp_program_data_pda_seeds, @@ -41,8 +42,7 @@ use hyperlane_sealevel_igp::{ const TEST_DESTINATION_DOMAIN: u32 = 11111; const TEST_GAS_AMOUNT: u64 = 300000; const TEST_GAS_OVERHEAD_AMOUNT: u64 = 100000; -const TOKEN_EXCHANGE_RATE_SCALE: u128 = 1e19 as u128; -const LOCAL_DECIMALS: u8 = 9; +const LOCAL_DECIMALS: u8 = SOL_DECIMALS; fn igp_program_id() -> Pubkey { pubkey!("BSffRJEwRcyEkjnbjAMMfv9kv3Y3SauxsBjCdNJyM2BN") From 25d915a5eb7560f1b3247427ba4af18124a9a300 Mon Sep 17 00:00:00 2001 From: Trevor Porter Date: Thu, 27 Jul 2023 10:36:01 +0100 Subject: [PATCH 4/5] nit --- rust/sealevel/.gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/sealevel/.gitignore b/rust/sealevel/.gitignore index 1276e94d63..b21c3b0132 100644 --- a/rust/sealevel/.gitignore +++ b/rust/sealevel/.gitignore @@ -1,2 +1,2 @@ /target -environments/**/deploy-logs.txt +environments/**/deploy-logs.txt \ No newline at end of file From 035c46355bd4ad122d53d525589ed1d577ac073c Mon Sep 17 00:00:00 2001 From: Trevor Porter Date: Mon, 31 Jul 2023 10:40:21 +0100 Subject: [PATCH 5/5] PR comments, add DiscriminatorPrefixed test --- .../account-utils/src/discriminator.rs | 32 +++++++++++++++++-- .../interchain-gas-paymaster/Cargo.toml | 2 +- .../programs/validator-announce/Cargo.toml | 2 +- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/rust/sealevel/libraries/account-utils/src/discriminator.rs b/rust/sealevel/libraries/account-utils/src/discriminator.rs index 8d7e4f7b22..510b154fb2 100644 --- a/rust/sealevel/libraries/account-utils/src/discriminator.rs +++ b/rust/sealevel/libraries/account-utils/src/discriminator.rs @@ -52,8 +52,8 @@ where T: SizedData, { fn size(&self) -> usize { - // 8 byte discriminator prefix - 8 + self.data.size() + // Discriminator prefix + data + Discriminator::LENGTH + self.data.size() } } @@ -108,3 +108,31 @@ pub trait DiscriminatorDecode: DiscriminatorData + borsh::BorshDeserialize { // Auto-implement impl DiscriminatorDecode for T where T: DiscriminatorData + borsh::BorshDeserialize {} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_discriminator_prefixed_size() { + #[derive(BorshSerialize, BorshDeserialize)] + struct Foo { + a: u64, + } + + impl DiscriminatorData for Foo { + const DISCRIMINATOR: [u8; 8] = [2, 2, 2, 2, 2, 2, 2, 2]; + } + + impl SizedData for Foo { + fn size(&self) -> usize { + 8 + } + } + + let prefixed_foo = DiscriminatorPrefixed::new(Foo { a: 1 }); + let serialized_prefixed_foo = prefixed_foo.try_to_vec().unwrap(); + + assert_eq!(serialized_prefixed_foo.len(), prefixed_foo.size()); + } +} diff --git a/rust/sealevel/programs/interchain-gas-paymaster/Cargo.toml b/rust/sealevel/programs/interchain-gas-paymaster/Cargo.toml index 6f087416c6..2c5820ac73 100644 --- a/rust/sealevel/programs/interchain-gas-paymaster/Cargo.toml +++ b/rust/sealevel/programs/interchain-gas-paymaster/Cargo.toml @@ -22,7 +22,7 @@ thiserror.workspace = true getrandom.workspace = true [dev-dependencies] -solana-program-test = "1.14.13" +solana-program-test.workspace = true solana-sdk.workspace = true hyperlane-test-utils ={ path = "../../libraries/test-utils" } diff --git a/rust/sealevel/programs/validator-announce/Cargo.toml b/rust/sealevel/programs/validator-announce/Cargo.toml index de82b819b8..100df57b57 100644 --- a/rust/sealevel/programs/validator-announce/Cargo.toml +++ b/rust/sealevel/programs/validator-announce/Cargo.toml @@ -21,7 +21,7 @@ serializable-account-meta = { path = "../../libraries/serializable-account-meta" [dev-dependencies] hex.workspace = true -solana-program-test = "1.14.13" +solana-program-test.workspace = true solana-sdk.workspace = true hyperlane-test-utils ={ path = "../../libraries/test-utils" }