diff --git a/.changeset/long-llamas-fly.md b/.changeset/long-llamas-fly.md index c0d6d01e9b..f9da56dd0f 100644 --- a/.changeset/long-llamas-fly.md +++ b/.changeset/long-llamas-fly.md @@ -1,5 +1,5 @@ --- -"@hyperlane-xyz/cli": patch +'@hyperlane-xyz/cli': patch --- Suppress help on CLI failures diff --git a/.changeset/polite-bulldogs-sit.md b/.changeset/polite-bulldogs-sit.md index 8acf555fe5..6c68774325 100644 --- a/.changeset/polite-bulldogs-sit.md +++ b/.changeset/polite-bulldogs-sit.md @@ -1,5 +1,5 @@ --- -"@hyperlane-xyz/cli": patch +'@hyperlane-xyz/cli': patch --- Fix strategy flag propagation diff --git a/.changeset/spotty-guests-dance.md b/.changeset/spotty-guests-dance.md new file mode 100644 index 0000000000..39eb9b0f49 --- /dev/null +++ b/.changeset/spotty-guests-dance.md @@ -0,0 +1,5 @@ +--- +'@hyperlane-xyz/sdk': minor +--- + +Added new Sealevel tx submission and priority fee oracle params to agent config types diff --git a/rust/main/chains/hyperlane-sealevel/src/lib.rs b/rust/main/chains/hyperlane-sealevel/src/lib.rs index 90a2e01b66..6121bbe27f 100644 --- a/rust/main/chains/hyperlane-sealevel/src/lib.rs +++ b/rust/main/chains/hyperlane-sealevel/src/lib.rs @@ -23,8 +23,10 @@ mod log_meta_composer; mod mailbox; mod merkle_tree_hook; mod multisig_ism; +mod priority_fee; mod provider; mod rpc; mod trait_builder; +mod tx_submitter; mod utils; mod validator_announce; diff --git a/rust/main/chains/hyperlane-sealevel/src/mailbox.rs b/rust/main/chains/hyperlane-sealevel/src/mailbox.rs index 66e58131aa..034f24c027 100644 --- a/rust/main/chains/hyperlane-sealevel/src/mailbox.rs +++ b/rust/main/chains/hyperlane-sealevel/src/mailbox.rs @@ -22,61 +22,37 @@ use hyperlane_sealevel_message_recipient_interface::{ }; use lazy_static::lazy_static; use serializable_account_meta::SimulationReturnData; -use solana_client::{rpc_client::SerializableTransaction, rpc_response::Response}; use solana_program::pubkey; use solana_sdk::{ account::Account, - bs58, clock::Slot, commitment_config::CommitmentConfig, - compute_budget::ComputeBudgetInstruction, instruction::{AccountMeta, Instruction}, pubkey::Pubkey, - signature::Signature, signer::{keypair::Keypair, Signer as _}, - transaction::Transaction, }; -use solana_transaction_status::TransactionStatus; use tracing::{debug, info, instrument, warn}; use hyperlane_core::{ config::StrOrIntParseError, ChainCommunicationError, ChainResult, ContractLocator, Decode as _, Encode as _, FixedPointNumber, HyperlaneChain, HyperlaneContract, HyperlaneDomain, - HyperlaneMessage, HyperlaneProvider, Indexed, Indexer, KnownHyperlaneDomain, LogMeta, Mailbox, - MerkleTreeHook, ReorgPeriod, SequenceAwareIndexer, TxCostEstimate, TxOutcome, H256, H512, U256, + HyperlaneMessage, HyperlaneProvider, Indexed, Indexer, LogMeta, Mailbox, MerkleTreeHook, + ReorgPeriod, SequenceAwareIndexer, TxCostEstimate, TxOutcome, H256, H512, U256, }; -use crate::account::{search_accounts_by_discriminator, search_and_validate_account}; use crate::log_meta_composer::{ is_message_delivery_instruction, is_message_dispatch_instruction, LogMetaComposer, }; +use crate::tx_submitter::TransactionSubmitter; +use crate::{ + account::{search_accounts_by_discriminator, search_and_validate_account}, + priority_fee::PriorityFeeOracle, +}; use crate::{ConnectionConf, SealevelProvider, SealevelRpcClient}; const SYSTEM_PROGRAM: &str = "11111111111111111111111111111111"; const SPL_NOOP: &str = "noopb9bkMVfRPU8AsbpTUg8AQkHtKwMYZiFUjNRtMmV"; -// The max amount of compute units for a transaction. -// TODO: consider a more sane value and/or use IGP gas payments instead. -const PROCESS_COMPUTE_UNITS: u32 = 1_400_000; - -/// 0.0005 SOL, in lamports. -/// A typical tx fee without a prioritization fee is 0.000005 SOL, or -/// 5000 lamports. (Example: https://explorer.solana.com/tx/fNd3xVeBzFHeuzr8dXQxLGiHMzTeYpykSV25xWzNRaHtzzjvY9A3MzXh1ZsK2JncRHkwtuWrGEwGXVhFaUCYhtx) -/// See average priority fees here https://solanacompass.com/statistics/fees -/// to inform what to spend here. -const PROCESS_DESIRED_PRIORITIZATION_FEE_LAMPORTS_PER_TX: u64 = 500000; - -/// In micro-lamports. Multiply this by the compute units to figure out -/// the additional cost of processing a message, in addition to the mandatory -/// "base" cost of signature verification. -/// Unused at the moment, but kept for future reference. -#[allow(dead_code)] -const PROCESS_COMPUTE_UNIT_PRICE_MICRO_LAMPORTS: u64 = - // Convert to micro-lamports - (PROCESS_DESIRED_PRIORITIZATION_FEE_LAMPORTS_PER_TX * 1_000_000) - // Divide by the max compute units - / PROCESS_COMPUTE_UNITS as u64; - // Earlier versions of collateral warp routes were deployed off a version where the mint // was requested as a writeable account for handle instruction. This is not necessary, // and generally requires a higher priority fee to be paid. @@ -96,6 +72,7 @@ lazy_static! { (pubkey!("CuQmsT4eSF4dYiiGUGYYQxJ7c58pUAD5ADE3BbFGzQKx"), pubkey!("EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm")), ]); } + /// A reference to a Mailbox contract on some Sealevel chain pub struct SealevelMailbox { pub(crate) program_id: Pubkey, @@ -103,6 +80,8 @@ pub struct SealevelMailbox { pub(crate) outbox: (Pubkey, u8), pub(crate) provider: SealevelProvider, payer: Option, + priority_fee_oracle: Box, + tx_submitter: Box, } impl SealevelMailbox { @@ -127,8 +106,12 @@ impl SealevelMailbox { program_id, inbox, outbox, - provider, payer, + priority_fee_oracle: conf.priority_fee_oracle.create_oracle(), + tx_submitter: conf + .transaction_submitter + .create_submitter(provider.rpc().url()), + provider, }) } @@ -301,122 +284,88 @@ impl SealevelMailbox { self.get_account_metas(instruction).await } - fn use_jito(&self) -> bool { - matches!( - self.domain(), - HyperlaneDomain::Known(KnownHyperlaneDomain::SolanaMainnet) - ) - } - - async fn send_and_confirm_transaction( + async fn get_process_instruction( &self, - transaction: &Transaction, - ) -> ChainResult { - if self.use_jito() { - self.send_and_confirm_transaction_with_jito(transaction) - .await - } else { - self.provider - .rpc() - .send_and_confirm_transaction(transaction) - .await - } - } + message: &HyperlaneMessage, + metadata: &[u8], + ) -> ChainResult { + let recipient: Pubkey = message.recipient.0.into(); + let mut encoded_message = vec![]; + message.write_to(&mut encoded_message).unwrap(); - /// Send a transaction to Jito and wait for it to be confirmed. - /// Logic stolen from Solana's non-blocking client. - pub async fn send_and_confirm_transaction_with_jito( - &self, - transaction: &impl SerializableTransaction, - ) -> ChainResult { - let signature = transaction.get_signature(); + let payer = self.get_payer()?; - let base58_txn = bs58::encode( - bincode::serialize(&transaction).map_err(ChainCommunicationError::from_other)?, + let (process_authority_key, _process_authority_bump) = Pubkey::try_find_program_address( + mailbox_process_authority_pda_seeds!(&recipient), + &self.program_id, ) - .into_string(); - - const SEND_RETRIES: usize = 1; - const GET_STATUS_RETRIES: usize = usize::MAX; - - 'sending: for _ in 0..SEND_RETRIES { - let jito_request_body = serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "sendBundle", - "params": [ - [base58_txn] - ], + .ok_or_else(|| { + ChainCommunicationError::from_other_str( + "Could not find program address for process authority", + ) + })?; + let (processed_message_account_key, _processed_message_account_bump) = + Pubkey::try_find_program_address( + mailbox_processed_message_pda_seeds!(message.id()), + &self.program_id, + ) + .ok_or_else(|| { + ChainCommunicationError::from_other_str( + "Could not find program address for processed message account", + ) + })?; + + // Get the account metas required for the recipient.InterchainSecurityModule instruction. + let ism_getter_account_metas = self.get_ism_getter_account_metas(recipient).await?; + + // Get the recipient ISM. + let ism = self + .get_recipient_ism(recipient, ism_getter_account_metas.clone()) + .await?; + + let ixn = + hyperlane_sealevel_mailbox::instruction::Instruction::InboxProcess(InboxProcess { + metadata: metadata.to_vec(), + message: encoded_message.clone(), }); + let ixn_data = ixn + .into_instruction_data() + .map_err(ChainCommunicationError::from_other)?; - tracing::info!( - ?jito_request_body, - ?signature, - "Sending sealevel transaction to Jito as bundle" - ); + // Craft the accounts for the transaction. + let mut accounts: Vec = vec![ + AccountMeta::new_readonly(payer.pubkey(), true), + AccountMeta::new_readonly(Pubkey::from_str(SYSTEM_PROGRAM).unwrap(), false), + AccountMeta::new(self.inbox.0, false), + AccountMeta::new_readonly(process_authority_key, false), + AccountMeta::new(processed_message_account_key, false), + ]; + accounts.extend(ism_getter_account_metas); + accounts.extend([ + AccountMeta::new_readonly(Pubkey::from_str(SPL_NOOP).unwrap(), false), + AccountMeta::new_readonly(ism, false), + ]); - let jito_response = reqwest::Client::new() - .post("https://mainnet.block-engine.jito.wtf:443/api/v1/bundles") - .json(&jito_request_body) - .send() - .await - .map_err(ChainCommunicationError::from_other)?; - let jito_response_text = jito_response.text().await; - - tracing::info!( - ?signature, - ?jito_response_text, - "Got Jito response for sealevel transaction bundle" - ); + // Get the account metas required for the ISM.Verify instruction. + let ism_verify_account_metas = self + .get_ism_verify_account_metas(ism, metadata.into(), encoded_message) + .await?; + accounts.extend(ism_verify_account_metas); - let recent_blockhash = if transaction.uses_durable_nonce() { - self.provider - .rpc() - .get_latest_blockhash_with_commitment(CommitmentConfig::processed()) - .await? - } else { - *transaction.get_recent_blockhash() - }; - - for status_retry in 0..GET_STATUS_RETRIES { - let signature_statuses: Response>> = self - .provider - .rpc() - .get_signature_statuses(&[*signature]) - .await?; - let signature_status = signature_statuses.value.first().cloned().flatten(); - match signature_status { - Some(_) => return Ok(*signature), - None => { - if !self - .provider - .rpc() - .is_blockhash_valid(&recent_blockhash) - .await? - { - // Block hash is not found by some reason - break 'sending; - } else if cfg!(not(test)) - // Ignore sleep at last step. - && status_retry < GET_STATUS_RETRIES - { - // Retry twice a second - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - continue; - } - } - } - } - } + // The recipient. + accounts.extend([AccountMeta::new_readonly(recipient, false)]); - Err(ChainCommunicationError::from_other( - solana_client::rpc_request::RpcError::ForUser( - "unable to confirm transaction. \ - This can happen in situations such as transaction expiration \ - and insufficient fee-payer funds" - .to_string(), - ), - )) + // Get account metas required for the Handle instruction + let handle_account_metas = self.get_handle_account_metas(message).await?; + accounts.extend(handle_account_metas); + + let process_instruction = Instruction { + program_id: self.program_id, + data: ixn_data, + accounts, + }; + + Ok(process_instruction) } async fn get_inbox(&self) -> ChainResult> { @@ -429,6 +378,12 @@ impl SealevelMailbox { .into_inner(); Ok(inbox) } + + fn get_payer(&self) -> ChainResult<&Keypair> { + self.payer + .as_ref() + .ok_or_else(|| ChainCommunicationError::SignerUnavailable) + } } impl HyperlaneContract for SealevelMailbox { @@ -508,133 +463,42 @@ impl Mailbox for SealevelMailbox { metadata: &[u8], _tx_gas_limit: Option, ) -> ChainResult { - let recipient: Pubkey = message.recipient.0.into(); - let mut encoded_message = vec![]; - message.write_to(&mut encoded_message).unwrap(); - - let payer = self - .payer - .as_ref() - .ok_or_else(|| ChainCommunicationError::SignerUnavailable)?; - - let mut instructions = Vec::with_capacity(3); - // Set the compute unit limit. - instructions.push(ComputeBudgetInstruction::set_compute_unit_limit( - PROCESS_COMPUTE_UNITS, - )); - - // If we're using Jito, we need to send a tip to the Jito fee account. - // Otherwise, we need to set the compute unit price. - if self.use_jito() { - let tip: u64 = std::env::var("JITO_TIP_LAMPORTS") - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(PROCESS_DESIRED_PRIORITIZATION_FEE_LAMPORTS_PER_TX); - - // The tip is a standalone transfer to a Jito fee account. - // See https://github.com/jito-labs/mev-protos/blob/master/json_rpc/http.md#sendbundle. - instructions.push(solana_sdk::system_instruction::transfer( - &payer.pubkey(), - // A random Jito fee account, taken from the getFeeAccount RPC response: - // https://github.com/jito-labs/mev-protos/blob/master/json_rpc/http.md#gettipaccounts - &solana_sdk::pubkey!("DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh"), - tip, - )); - } // "processed" level commitment does not guarantee finality. // roughly 5% of blocks end up on a dropped fork. // However we don't want this function to be a bottleneck and there already // is retry logic in the agents. let commitment = CommitmentConfig::processed(); - let (process_authority_key, _process_authority_bump) = Pubkey::try_find_program_address( - mailbox_process_authority_pda_seeds!(&recipient), - &self.program_id, - ) - .ok_or_else(|| { - ChainCommunicationError::from_other_str( - "Could not find program address for process authority", - ) - })?; - let (processed_message_account_key, _processed_message_account_bump) = - Pubkey::try_find_program_address( - mailbox_processed_message_pda_seeds!(message.id()), - &self.program_id, - ) - .ok_or_else(|| { - ChainCommunicationError::from_other_str( - "Could not find program address for processed message account", - ) - })?; + let process_instruction = self.get_process_instruction(message, metadata).await?; - // Get the account metas required for the recipient.InterchainSecurityModule instruction. - let ism_getter_account_metas = self.get_ism_getter_account_metas(recipient).await?; - - // Get the recipient ISM. - let ism = self - .get_recipient_ism(recipient, ism_getter_account_metas.clone()) - .await?; - - let ixn = - hyperlane_sealevel_mailbox::instruction::Instruction::InboxProcess(InboxProcess { - metadata: metadata.to_vec(), - message: encoded_message.clone(), - }); - let ixn_data = ixn - .into_instruction_data() - .map_err(ChainCommunicationError::from_other)?; - - // Craft the accounts for the transaction. - let mut accounts: Vec = vec![ - AccountMeta::new_readonly(payer.pubkey(), true), - AccountMeta::new_readonly(Pubkey::from_str(SYSTEM_PROGRAM).unwrap(), false), - AccountMeta::new(self.inbox.0, false), - AccountMeta::new_readonly(process_authority_key, false), - AccountMeta::new(processed_message_account_key, false), - ]; - accounts.extend(ism_getter_account_metas); - accounts.extend([ - AccountMeta::new_readonly(Pubkey::from_str(SPL_NOOP).unwrap(), false), - AccountMeta::new_readonly(ism, false), - ]); - - // Get the account metas required for the ISM.Verify instruction. - let ism_verify_account_metas = self - .get_ism_verify_account_metas(ism, metadata.into(), encoded_message) + let tx = self + .provider + .rpc() + .build_estimated_tx_for_instruction( + process_instruction, + self.get_payer()?, + &*self.tx_submitter, + &*self.priority_fee_oracle, + ) .await?; - accounts.extend(ism_verify_account_metas); - // The recipient. - accounts.extend([AccountMeta::new_readonly(recipient, false)]); + tracing::info!(?tx, "Created sealevel transaction to process message"); - // Get account metas required for the Handle instruction - let handle_account_metas = self.get_handle_account_metas(message).await?; - accounts.extend(handle_account_metas); + let signature = self.tx_submitter.send_transaction(&tx, true).await?; - let inbox_instruction = Instruction { - program_id: self.program_id, - data: ixn_data, - accounts, - }; - instructions.push(inbox_instruction); - let recent_blockhash = self - .rpc() - .get_latest_blockhash_with_commitment(commitment) - .await?; + tracing::info!(?tx, ?signature, "Sealevel transaction sent"); - let txn = Transaction::new_signed_with_payer( - &instructions, - Some(&payer.pubkey()), - &[payer], - recent_blockhash, - ); + let send_instant = std::time::Instant::now(); - tracing::info!(?txn, "Created sealevel transaction to process message"); + // Wait for the transaction to be confirmed. + self.rpc().wait_for_transaction_confirmation(&tx).await?; - let signature = self.send_and_confirm_transaction(&txn).await?; - - tracing::info!(?txn, ?signature, "Sealevel transaction sent"); + // We expect time_to_confirm to fluctuate depending on the commitment level when submitting the + // tx, but still use it as a proxy for tx latency to help debug. + tracing::info!(?tx, ?signature, time_to_confirm=?send_instant.elapsed(), "Sealevel transaction confirmed"); + // TODO: not sure if this actually checks if the transaction was executed / reverted? + // Confirm the transaction. let executed = self .rpc() .confirm_transaction_with_commitment(&signature, commitment) @@ -655,10 +519,30 @@ impl Mailbox for SealevelMailbox { #[instrument(err, ret, skip(self))] async fn process_estimate_costs( &self, - _message: &HyperlaneMessage, - _metadata: &[u8], + message: &HyperlaneMessage, + metadata: &[u8], ) -> ChainResult { - // TODO use correct data upon integrating IGP support + // Getting a process instruction in Sealevel is a pretty expensive operation + // that involves some view calls. Consider reusing the instruction with subsequent + // calls to `process` to avoid this cost. + let process_instruction = self.get_process_instruction(message, metadata).await?; + + // The returned costs are unused at the moment - we simply want to perform a simulation to + // determine if the message will revert or not. + let _ = self + .rpc() + .get_estimated_costs_for_instruction( + process_instruction, + self.get_payer()?, + &*self.tx_submitter, + &*self.priority_fee_oracle, + ) + .await?; + + // TODO use correct data upon integrating IGP support. + // NOTE: providing a real gas limit here will result in accurately enforcing + // gas payments. Be careful rolling this out to not impact existing contracts + // that may not be paying for super accurate gas amounts. Ok(TxCostEstimate { gas_limit: U256::zero(), gas_price: FixedPointNumber::zero(), diff --git a/rust/main/chains/hyperlane-sealevel/src/priority_fee.rs b/rust/main/chains/hyperlane-sealevel/src/priority_fee.rs new file mode 100644 index 0000000000..2df395cce6 --- /dev/null +++ b/rust/main/chains/hyperlane-sealevel/src/priority_fee.rs @@ -0,0 +1,218 @@ +use async_trait::async_trait; +use derive_new::new; +use hyperlane_core::{ChainCommunicationError, ChainResult}; +use reqwest::Client; +use serde::Deserialize; +use solana_sdk::{bs58, transaction::Transaction}; + +use crate::{HeliusPriorityFeeLevel, HeliusPriorityFeeOracleConfig}; + +/// A trait for fetching the priority fee for a transaction. +#[async_trait] +pub trait PriorityFeeOracle: Send + Sync { + /// Fetch the priority fee in microlamports for a transaction. + async fn get_priority_fee(&self, transaction: &Transaction) -> ChainResult; +} + +/// A priority fee oracle that returns a constant fee. +#[derive(Debug, Clone, new)] +pub struct ConstantPriorityFeeOracle { + fee: u64, +} + +#[async_trait] +impl PriorityFeeOracle for ConstantPriorityFeeOracle { + async fn get_priority_fee(&self, _transaction: &Transaction) -> ChainResult { + Ok(self.fee) + } +} + +/// A priority fee oracle that fetches the fee from the Helius API. +/// https://docs.helius.dev/solana-apis/priority-fee-api +#[derive(Debug, Clone)] +pub struct HeliusPriorityFeeOracle { + client: Client, + config: HeliusPriorityFeeOracleConfig, +} + +impl HeliusPriorityFeeOracle { + pub fn new(config: HeliusPriorityFeeOracleConfig) -> Self { + Self { + client: reqwest::Client::new(), + config, + } + } + + fn get_priority_fee_estimate_options(&self) -> serde_json::Value { + // It's an odd interface, but if using the Recommended fee level, the API requires `recommended: true`, + // otherwise it requires `priorityLevel: ""`. + + let (key, value) = match &self.config.fee_level { + HeliusPriorityFeeLevel::Recommended => ("recommended", serde_json::json!(true)), + level => ("priorityLevel", serde_json::json!(level)), + }; + + serde_json::json!({ + key: value, + "transactionEncoding": "base58", + }) + } +} + +#[async_trait] +impl PriorityFeeOracle for HeliusPriorityFeeOracle { + async fn get_priority_fee(&self, transaction: &Transaction) -> ChainResult { + let base58_tx = bs58::encode( + bincode::serialize(transaction).map_err(ChainCommunicationError::from_other)?, + ) + .into_string(); + + let request_body = serde_json::json!({ + "jsonrpc": "2.0", + "id": "1", + "method": "getPriorityFeeEstimate", + "params": [ + { + "transaction": base58_tx, + "options": self.get_priority_fee_estimate_options(), + } + ], + }); + + let response = self + .client + .post(self.config.url.clone()) + .json(&request_body) + .send() + .await + .map_err(ChainCommunicationError::from_other)?; + + let response: JsonRpcResult = response + .json() + .await + .map_err(ChainCommunicationError::from_other)?; + + tracing::debug!(?response, "Fetched priority fee from Helius API"); + + let fee = response.result.priority_fee_estimate.round() as u64; + + Ok(fee) + } +} + +/// The result of a JSON-RPC request to the Helius API. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct JsonRpcResult { + #[allow(dead_code)] + jsonrpc: String, + #[allow(dead_code)] + id: String, + result: T, +} + +/// The result of a `getPriorityFeeEstimate` request to the Helius API. +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct GetPriorityFeeEstimateResult { + priority_fee_estimate: f64, +} + +#[cfg(test)] +mod test { + use solana_sdk::{bs58, transaction::Transaction}; + + use crate::{ + priority_fee::{HeliusPriorityFeeOracle, PriorityFeeOracle}, + HeliusPriorityFeeLevel, HeliusPriorityFeeOracleConfig, + }; + + use super::{GetPriorityFeeEstimateResult, JsonRpcResult}; + + #[tokio::test] + async fn test_helius_get_priority_fee() { + let helius_url = if let Ok(url) = std::env::var("HELIUS_URL") { + url + } else { + // Skip test if HELIUS_URL is not set + return; + }; + + let oracle = super::HeliusPriorityFeeOracle::new(super::HeliusPriorityFeeOracleConfig { + url: url::Url::parse(&helius_url).unwrap(), + fee_level: super::HeliusPriorityFeeLevel::Medium, + }); + + // Example process transaction + // https://solscan.io/tx/W9fXtRD8mPkkUmuoLi9QxSCgFuy32rCVa8kfxtPjWXWRH2D1AWzuDEGuvexWGyWhQDXnEmaADZMeYu5RVjWZyAB + let process_tx_base58 = "BPBE2dE4sPJX3nm4svEZ181qBfX9yvUp5H67uTt3aqRGtC6a77hW5vrQk9zJ3KkNuK63KoJCeqp1kkFwsbF5KL1UHf5Hrj8GXpiRxmKD8NybEZUWhjdVW9azMxJdnxxiFqH7wFQtZGkQxhx6oJz1qi5Xc64LEbPJEwSTAp5US1VCnnhWGRqJ297kvS8hWaVLuUxr4jEqYNG2LSusXZmzABBqEvRv753PBxcKiBE2moo9VKZ8n3ai6rmQGnSzsoAfwnjCx6iUdNSWqpYFHcq2xhMXJx8US5kv837KsT5tKQBbujsWUoRGGJ8vkmm7RJSYyR3DYEMa5ira9fiDwnK5qP3EgP2hrG73YYBxZ9naRrYzHG2GiEGWEUgNPHaUtK3JsbjTLiNjyZU8ERTdMxi4rBLppREJfHDWYUNgN9hTL81LYv4YoJY3UUTQphzT268f6oZoiyavngb8t3Lq8pbyc3gPiw7AcWXmn2ERDAcHvS59AaoxxcwZyn8UWUdynwCzvNbWhb97qVHSzBY1S79sxHFuqyBhbbD5YhkMhFGLjPUEDvncxE2hLt9iQCQaEQzCNRMmnZw7yJ1YxoKDKfmUTXJ6rmT4p2pz7f8x4jJwQ2pC2YxobcfHrNvD7929vXSvpomyZmaEXYAN2bqGBUe2KazpnobVCwafjKMVN4AaTJRMTXi92VKuShuKJEuZo9ZM7TScEqRZC5hLFU8SbCdASEUoQjpDzivUf1m9gQtT2ob5FPwJzcuZpqTWgixd59BRHTB1L5c4fDvtYr1QJFpJRN4DsXGryK4eTMu2oAs3imGpg1rHRLpuBTbcrchEivz7bD17bBj8VeHogfkPcehD9yaHzmYPRF47aWZ52GSFSSpc5kJRRQyghUKNPFBnycLGAbfkRYDdVzUgdrr3CNYksJCu45TChg54tMWWwrqSD3k5RPv7A6bXbAH4PzW83vzE2vGJFYpwUgNEnjuA1rVnYJHXsFdWBrqrsz3UvdTs5kUxyoxjNNKvoXSaTeXMXEt1HUdmQ3sw1dW9wRkYdHwWzksM6n7P7MLnVY6qv3BVUpJiX4K355BXhMhyozzcBQX2vvyC7J8UxPBofMrBRVtbMsXmfp3sphos1pog6wpN2MiEaJqm6KK5yQguANnQzN8mK7MREkjYXtCnczf84CrcHqpp2onQUaR4TPn8zCPVAxY4HVkCoDWTwKj8Am9M4L3a7wmF37epgKnQuypTH7dqbJPRTALe7tndrtvJCuoTFP8wPXQXxvwnBPXeLmhK9E2mpskTA33KfqvVBu4R5SFYNtGoKbvuHaDf83Lf2xx1YPUogXuEWZMx5zcaHWMmvutpfdnPe3Rb7GL4hPVKj4t9MNgiAg3QbjaR9nqYBUPT4kUpxVCJWEadDVh5pgLwnkg4DJ5ArNfgH5"; + let process_tx_bytes = bs58::decode(process_tx_base58).into_vec().unwrap(); + let transaction: Transaction = bincode::deserialize(&process_tx_bytes).unwrap(); + + oracle.get_priority_fee(&transaction).await.unwrap(); + } + + #[test] + fn test_helius_get_priority_fee_estimate_options_ser() { + let get_oracle = |fee_level| { + HeliusPriorityFeeOracle::new(HeliusPriorityFeeOracleConfig { + url: url::Url::parse("http://localhost:8080").unwrap(), + fee_level, + }) + }; + + // When the fee level is Recommended, ensure `recommended` is set to true + let oracle = get_oracle(HeliusPriorityFeeLevel::Recommended); + + let options = oracle.get_priority_fee_estimate_options(); + let expected = serde_json::json!({ + "recommended": true, + "transactionEncoding": "base58", + }); + assert_eq!(options, expected); + + // When the fee level is not Recommended, ensure `priorityLevel` is set + let oracle = get_oracle(HeliusPriorityFeeLevel::Medium); + + let options = oracle.get_priority_fee_estimate_options(); + let expected = serde_json::json!({ + "priorityLevel": "Medium", + "transactionEncoding": "base58", + }); + assert_eq!(options, expected); + + // Ensure the serialization of HeliusPriorityFeeLevel is PascalCase, + // as required by the API https://docs.helius.dev/solana-apis/priority-fee-api#helius-priority-fee-api + let serialized = serde_json::json!([ + HeliusPriorityFeeLevel::Recommended, + HeliusPriorityFeeLevel::Min, + HeliusPriorityFeeLevel::Low, + HeliusPriorityFeeLevel::Medium, + HeliusPriorityFeeLevel::High, + HeliusPriorityFeeLevel::VeryHigh, + HeliusPriorityFeeLevel::UnsafeMax, + ]); + let expected = serde_json::json!([ + "Recommended", + "Min", + "Low", + "Medium", + "High", + "VeryHigh", + "UnsafeMax" + ]); + assert_eq!(serialized, expected); + } + + #[test] + fn test_helius_get_priority_fee_estimate_deser() { + let text = r#"{"jsonrpc":"2.0","result":{"priorityFeeEstimate":1000.0},"id":"1"}"#; + let response: JsonRpcResult = + serde_json::from_str(text).unwrap(); + + let expected = GetPriorityFeeEstimateResult { + priority_fee_estimate: 1000.0, + }; + assert_eq!(response.result, expected); + } +} diff --git a/rust/main/chains/hyperlane-sealevel/src/rpc/client.rs b/rust/main/chains/hyperlane-sealevel/src/rpc/client.rs index 59adb671f3..22b44497c8 100644 --- a/rust/main/chains/hyperlane-sealevel/src/rpc/client.rs +++ b/rust/main/chains/hyperlane-sealevel/src/rpc/client.rs @@ -2,12 +2,18 @@ use base64::Engine; use borsh::{BorshDeserialize, BorshSerialize}; use serializable_account_meta::{SerializableAccountMeta, SimulationReturnData}; use solana_client::{ - nonblocking::rpc_client::RpcClient, rpc_config::RpcBlockConfig, - rpc_config::RpcProgramAccountsConfig, rpc_config::RpcTransactionConfig, rpc_response::Response, + nonblocking::rpc_client::RpcClient, + rpc_client::SerializableTransaction, + rpc_config::{ + RpcBlockConfig, RpcProgramAccountsConfig, RpcSendTransactionConfig, + RpcSimulateTransactionConfig, RpcTransactionConfig, + }, + rpc_response::{Response, RpcSimulateTransactionResult}, }; use solana_sdk::{ account::Account, commitment_config::CommitmentConfig, + compute_budget::ComputeBudgetInstruction, hash::Hash, instruction::{AccountMeta, Instruction}, message::Message, @@ -17,16 +23,27 @@ use solana_sdk::{ }; use solana_transaction_status::{ EncodedConfirmedTransactionWithStatusMeta, TransactionStatus, UiConfirmedBlock, - UiReturnDataEncoding, UiTransactionEncoding, UiTransactionReturnData, + UiReturnDataEncoding, UiTransactionEncoding, }; use hyperlane_core::{ChainCommunicationError, ChainResult, U256}; -use crate::error::HyperlaneSealevelError; +use crate::{ + error::HyperlaneSealevelError, priority_fee::PriorityFeeOracle, + tx_submitter::TransactionSubmitter, +}; + +pub struct SealevelTxCostEstimate { + compute_units: u32, + compute_unit_price_micro_lamports: u64, +} pub struct SealevelRpcClient(RpcClient); impl SealevelRpcClient { + /// The max amount of compute units for a transaction. + const MAX_COMPUTE_UNITS: u32 = 1_400_000; + pub fn new(rpc_endpoint: String) -> Self { Self(RpcClient::new_with_commitment( rpc_endpoint, @@ -199,16 +216,74 @@ impl SealevelRpcClient { .map_err(ChainCommunicationError::from_other) } - pub async fn send_and_confirm_transaction( + pub async fn send_transaction( &self, transaction: &Transaction, + skip_preflight: bool, ) -> ChainResult { self.0 - .send_and_confirm_transaction(transaction) + .send_transaction_with_config( + transaction, + RpcSendTransactionConfig { + skip_preflight, + ..Default::default() + }, + ) .await .map_err(ChainCommunicationError::from_other) } + /// Polls the RPC until the transaction is confirmed or the blockhash + /// expires. + /// Standalone logic stolen from Solana's non-blocking client, + /// decoupled from the sending of a transaction. + pub async fn wait_for_transaction_confirmation( + &self, + transaction: &impl SerializableTransaction, + ) -> ChainResult<()> { + let signature = transaction.get_signature(); + + const GET_STATUS_RETRIES: usize = usize::MAX; + + let recent_blockhash = if transaction.uses_durable_nonce() { + self.get_latest_blockhash_with_commitment(CommitmentConfig::processed()) + .await? + } else { + *transaction.get_recent_blockhash() + }; + + for status_retry in 0..GET_STATUS_RETRIES { + let signature_statuses: Response>> = + self.get_signature_statuses(&[*signature]).await?; + let signature_status = signature_statuses.value.first().cloned().flatten(); + match signature_status { + Some(_) => return Ok(()), + None => { + if !self.is_blockhash_valid(&recent_blockhash).await? { + // Block hash is not found by some reason + break; + } else if cfg!(not(test)) + // Ignore sleep at last step. + && status_retry < GET_STATUS_RETRIES + { + // Retry twice a second + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + continue; + } + } + } + } + + Err(ChainCommunicationError::from_other( + solana_client::rpc_request::RpcError::ForUser( + "unable to confirm transaction. \ + This can happen in situations such as transaction expiration \ + and insufficient fee-payer funds" + .to_string(), + ), + )) + } + /// Simulates an instruction, and attempts to deserialize it into a T. /// If no return data at all was returned, returns Ok(None). /// If some return data was returned but deserialization was unsuccessful, @@ -227,9 +302,9 @@ impl SealevelRpcClient { Some(&payer.pubkey()), &recent_blockhash, )); - let return_data = self.simulate_transaction(&transaction).await?; + let simulation = self.simulate_transaction(&transaction).await?; - if let Some(return_data) = return_data { + if let Some(return_data) = simulation.return_data { let bytes = match return_data.data.1 { UiReturnDataEncoding::Base64 => base64::engine::general_purpose::STANDARD .decode(return_data.data.0) @@ -245,19 +320,176 @@ impl SealevelRpcClient { Ok(None) } - async fn simulate_transaction( + pub async fn simulate_transaction( &self, transaction: &Transaction, - ) -> ChainResult> { - let return_data = self + ) -> ChainResult { + let result = self .0 - .simulate_transaction(transaction) + .simulate_transaction_with_config( + transaction, + RpcSimulateTransactionConfig { + sig_verify: false, + replace_recent_blockhash: true, + ..Default::default() + }, + ) .await .map_err(ChainCommunicationError::from_other)? - .value - .return_data; + .value; + + Ok(result) + } + + /// Gets the estimated costs for a given instruction. + pub async fn get_estimated_costs_for_instruction( + &self, + instruction: Instruction, + payer: &Keypair, + tx_submitter: &dyn TransactionSubmitter, + priority_fee_oracle: &dyn PriorityFeeOracle, + ) -> ChainResult { + // Build a transaction that sets the max compute units and a dummy compute unit price. + // This is used for simulation to get the actual compute unit limit. We set dummy values + // for the compute unit limit and price because we want to include the instructions that + // set these in the cost estimate. + let simulation_tx = self + .create_transaction_for_instruction( + Self::MAX_COMPUTE_UNITS, + 0, + instruction.clone(), + payer, + tx_submitter, + false, + ) + .await?; + + let simulation_result = self.simulate_transaction(&simulation_tx).await?; + + // If there was an error in the simulation result, return an error. + if simulation_result.err.is_some() { + tracing::error!(?simulation_result, "Got simulation result for transaction"); + return Err(ChainCommunicationError::from_other_str( + format!("Error in simulation result: {:?}", simulation_result.err).as_str(), + )); + } else { + tracing::debug!(?simulation_result, "Got simulation result for transaction"); + } + + // Get the compute units used in the simulation result, requiring + // that it is greater than 0. + let simulation_compute_units: u32 = simulation_result + .units_consumed + .unwrap_or_default() + .try_into() + .map_err(ChainCommunicationError::from_other)?; + if simulation_compute_units == 0 { + return Err(ChainCommunicationError::from_other_str( + "Empty or zero compute units returned in simulation result", + )); + } + + // Bump the compute units by 10% to ensure we have enough, but cap it at the max. + let simulation_compute_units = + Self::MAX_COMPUTE_UNITS.min((simulation_compute_units * 11) / 10); + + let priority_fee = priority_fee_oracle.get_priority_fee(&simulation_tx).await?; + + Ok(SealevelTxCostEstimate { + compute_units: simulation_compute_units, + compute_unit_price_micro_lamports: priority_fee, + }) + } + + /// Builds a transaction with estimated costs for a given instruction. + pub async fn build_estimated_tx_for_instruction( + &self, + instruction: Instruction, + payer: &Keypair, + tx_submitter: &dyn TransactionSubmitter, + priority_fee_oracle: &dyn PriorityFeeOracle, + ) -> ChainResult { + // Get the estimated costs for the instruction. + let SealevelTxCostEstimate { + compute_units, + compute_unit_price_micro_lamports, + } = self + .get_estimated_costs_for_instruction( + instruction.clone(), + payer, + tx_submitter, + priority_fee_oracle, + ) + .await?; + + tracing::info!( + ?compute_units, + ?compute_unit_price_micro_lamports, + "Got compute units and compute unit price / priority fee for transaction" + ); + + // Build the final transaction with the correct compute unit limit and price. + let tx = self + .create_transaction_for_instruction( + compute_units, + compute_unit_price_micro_lamports, + instruction, + payer, + tx_submitter, + true, + ) + .await?; + + Ok(tx) + } + + /// Creates a transaction for a given instruction, compute unit limit, and compute unit price. + /// If `sign` is true, the transaction will be signed. + pub async fn create_transaction_for_instruction( + &self, + compute_unit_limit: u32, + compute_unit_price_micro_lamports: u64, + instruction: Instruction, + payer: &Keypair, + tx_submitter: &dyn TransactionSubmitter, + sign: bool, + ) -> ChainResult { + let instructions = vec![ + // Set the compute unit limit. + ComputeBudgetInstruction::set_compute_unit_limit(compute_unit_limit), + // Set the priority fee / tip + tx_submitter.get_priority_fee_instruction( + compute_unit_price_micro_lamports, + compute_unit_limit.into(), + &payer.pubkey(), + ), + instruction, + ]; + + let tx = if sign { + // Getting the finalized blockhash eliminates the chance the blockhash + // gets reorged out, causing the tx to be invalid. The tradeoff is this + // will cause the tx to expire in about 47 seconds (instead of the typical 60). + let recent_blockhash = self + .get_latest_blockhash_with_commitment(CommitmentConfig::finalized()) + .await + .map_err(ChainCommunicationError::from_other)?; + + Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer], + recent_blockhash, + ) + } else { + Transaction::new_unsigned(Message::new(&instructions, Some(&payer.pubkey()))) + }; + + Ok(tx) + } - Ok(return_data) + pub fn url(&self) -> String { + self.0.url() } } diff --git a/rust/main/chains/hyperlane-sealevel/src/trait_builder.rs b/rust/main/chains/hyperlane-sealevel/src/trait_builder.rs index e0b7c5cb37..13cc0b90a4 100644 --- a/rust/main/chains/hyperlane-sealevel/src/trait_builder.rs +++ b/rust/main/chains/hyperlane-sealevel/src/trait_builder.rs @@ -1,6 +1,12 @@ use hyperlane_core::{config::OperationBatchConfig, ChainCommunicationError, NativeToken}; +use serde::Serialize; use url::Url; +use crate::{ + priority_fee::{ConstantPriorityFeeOracle, HeliusPriorityFeeOracle, PriorityFeeOracle}, + tx_submitter::{JitoTransactionSubmitter, RpcTransactionSubmitter, TransactionSubmitter}, +}; + /// Sealevel connection configuration #[derive(Debug, Clone)] pub struct ConnectionConf { @@ -10,6 +16,10 @@ pub struct ConnectionConf { pub operation_batch: OperationBatchConfig, /// Native token and its denomination pub native_token: NativeToken, + /// Priority fee oracle configuration + pub priority_fee_oracle: PriorityFeeOracleConfig, + /// Transaction submitter configuration + pub transaction_submitter: TransactionSubmitterConfig, } /// An error type when parsing a connection configuration. @@ -23,6 +33,106 @@ pub enum ConnectionConfError { InvalidConnectionUrl(String, url::ParseError), } +/// Configuration to of how the priority fee should be determined +#[derive(Debug, Clone)] +pub enum PriorityFeeOracleConfig { + /// A constant value, in micro lamports + Constant(u64), + /// A Helius priority fee oracle + Helius(HeliusPriorityFeeOracleConfig), +} + +impl Default for PriorityFeeOracleConfig { + fn default() -> Self { + PriorityFeeOracleConfig::Constant(0) + } +} + +impl PriorityFeeOracleConfig { + /// Create a new priority fee oracle from the configuration + pub fn create_oracle(&self) -> Box { + match self { + PriorityFeeOracleConfig::Constant(fee) => { + Box::new(ConstantPriorityFeeOracle::new(*fee)) + } + PriorityFeeOracleConfig::Helius(config) => { + Box::new(HeliusPriorityFeeOracle::new(config.clone())) + } + } + } +} + +/// Configuration for the Helius priority fee oracle +#[derive(Debug, Clone)] +pub struct HeliusPriorityFeeOracleConfig { + /// The Helius URL to use + pub url: Url, + /// The fee level to use + pub fee_level: HeliusPriorityFeeLevel, +} + +/// The priority fee level to use +#[derive(Debug, Clone, Serialize, Default)] +pub enum HeliusPriorityFeeLevel { + /// 50th percentile, but a floor of 10k microlamports. + /// The floor results in a staked Helius connection being used. (https://docs.helius.dev/guides/sending-transactions-on-solana#staked-connections) + #[default] + Recommended, + /// 0th percentile + Min, + /// 10th percentile + Low, + /// 50th percentile + Medium, + /// 75th percentile + High, + /// 90th percentile + VeryHigh, + /// 100th percentile + UnsafeMax, +} + +/// Configuration for the transaction submitter +#[derive(Debug, Clone)] +pub enum TransactionSubmitterConfig { + /// Use the RPC transaction submitter + Rpc { + /// The URL to use. If not provided, a default RPC URL will be used + url: Option, + }, + /// Use the Jito transaction submitter + Jito { + /// The URL to use. If not provided, a default Jito URL will be used + url: Option, + }, +} + +impl Default for TransactionSubmitterConfig { + fn default() -> Self { + TransactionSubmitterConfig::Rpc { url: None } + } +} + +impl TransactionSubmitterConfig { + /// Create a new transaction submitter from the configuration + pub fn create_submitter(&self, default_rpc_url: String) -> Box { + match self { + TransactionSubmitterConfig::Rpc { url } => Box::new(RpcTransactionSubmitter::new( + url.clone().unwrap_or(default_rpc_url), + )), + TransactionSubmitterConfig::Jito { url } => { + // Default to a bundle-only URL (i.e. revert protected) + Box::new(JitoTransactionSubmitter::new(url.clone().unwrap_or_else( + || { + "https://mainnet.block-engine.jito.wtf/api/v1/transactions?bundleOnly=true" + .to_string() + }, + ))) + } + } + } +} + #[derive(thiserror::Error, Debug)] #[error(transparent)] struct SealevelNewConnectionError(#[from] anyhow::Error); diff --git a/rust/main/chains/hyperlane-sealevel/src/tx_submitter.rs b/rust/main/chains/hyperlane-sealevel/src/tx_submitter.rs new file mode 100644 index 0000000000..c7468d0f24 --- /dev/null +++ b/rust/main/chains/hyperlane-sealevel/src/tx_submitter.rs @@ -0,0 +1,115 @@ +use async_trait::async_trait; +use hyperlane_core::ChainResult; +use solana_sdk::{ + compute_budget::ComputeBudgetInstruction, instruction::Instruction, pubkey::Pubkey, + signature::Signature, transaction::Transaction, +}; + +use crate::SealevelRpcClient; + +/// A trait for submitting transactions to the chain. +#[async_trait] +pub trait TransactionSubmitter: Send + Sync { + /// Get the instruction to set the compute unit price. + fn get_priority_fee_instruction( + &self, + compute_unit_price_micro_lamports: u64, + compute_units: u64, + payer: &Pubkey, + ) -> Instruction; + + /// Send a transaction to the chain. + async fn send_transaction( + &self, + transaction: &Transaction, + skip_preflight: bool, + ) -> ChainResult; +} + +/// A transaction submitter that uses the vanilla RPC to submit transactions. +#[derive(Debug)] +pub struct RpcTransactionSubmitter { + rpc_client: SealevelRpcClient, +} + +impl RpcTransactionSubmitter { + pub fn new(url: String) -> Self { + Self { + rpc_client: SealevelRpcClient::new(url), + } + } +} + +#[async_trait] +impl TransactionSubmitter for RpcTransactionSubmitter { + fn get_priority_fee_instruction( + &self, + compute_unit_price_micro_lamports: u64, + _compute_units: u64, + _payer: &Pubkey, + ) -> Instruction { + ComputeBudgetInstruction::set_compute_unit_price(compute_unit_price_micro_lamports) + } + + async fn send_transaction( + &self, + transaction: &Transaction, + skip_preflight: bool, + ) -> ChainResult { + self.rpc_client + .send_transaction(transaction, skip_preflight) + .await + } +} + +/// A transaction submitter that uses the Jito API to submit transactions. +#[derive(Debug)] +pub struct JitoTransactionSubmitter { + rpc_client: SealevelRpcClient, +} + +impl JitoTransactionSubmitter { + /// The minimum tip to include in a transaction. + /// From https://docs.jito.wtf/lowlatencytxnsend/#sendtransaction + const MINIMUM_TIP_LAMPORTS: u64 = 1000; + + pub fn new(url: String) -> Self { + Self { + rpc_client: SealevelRpcClient::new(url), + } + } +} + +#[async_trait] +impl TransactionSubmitter for JitoTransactionSubmitter { + fn get_priority_fee_instruction( + &self, + compute_unit_price_micro_lamports: u64, + compute_units: u64, + payer: &Pubkey, + ) -> Instruction { + // Divide by 1_000_000 to convert from microlamports to lamports. + let tip_lamports = (compute_units * compute_unit_price_micro_lamports) / 1_000_000; + let tip_lamports = tip_lamports.max(Self::MINIMUM_TIP_LAMPORTS); + + // The tip is a standalone transfer to a Jito fee account. + // See https://github.com/jito-labs/mev-protos/blob/master/json_rpc/http.md#sendbundle. + solana_sdk::system_instruction::transfer( + payer, + // A random Jito fee account, taken from the getFeeAccount RPC response: + // https://github.com/jito-labs/mev-protos/blob/master/json_rpc/http.md#gettipaccounts + &solana_sdk::pubkey!("DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh"), + tip_lamports, + ) + } + + async fn send_transaction( + &self, + transaction: &Transaction, + skip_preflight: bool, + ) -> ChainResult { + self.rpc_client + .send_transaction(transaction, skip_preflight) + .await + } +} diff --git a/rust/main/config/mainnet_config.json b/rust/main/config/mainnet_config.json index 5b465b90cf..e7704ff783 100644 --- a/rust/main/config/mainnet_config.json +++ b/rust/main/config/mainnet_config.json @@ -785,7 +785,7 @@ "index": { "from": 1, "mode": "sequence", - "chunk": 100 + "chunk": 20 }, "interchainGasPaymaster": "ABb3i11z7wKoGCfeRQNQbVYWjAm7jG7HzZnDLV4RKRbK", "mailbox": "EitxJuv2iBjsg2d7jVy2LDC1e2zBrx4GB5Y9h2Ko3A9Y", @@ -2762,7 +2762,8 @@ "gasCurrencyCoinGeckoId": "sei-network", "gnosisSafeTransactionServiceUrl": "https://transaction.sei-safe.protofire.io", "index": { - "from": 80809403 + "from": 80809403, + "chunk": 1000 }, "interchainAccountIsm": "0xf35dc7B9eE4Ebf0cd3546Bd6EE3b403dE2b9F5D6", "interchainAccountRouter": "0xBcaedE97a98573A88242B3b0CB0A255F3f90d4d5", @@ -2828,7 +2829,7 @@ "index": { "from": 1, "mode": "sequence", - "chunk": 100 + "chunk": 20 }, "interchainGasPaymaster": "JAvHW21tYXE9dtdG83DReqU2b4LUexFuCbtJT5tF8X6M", "mailbox": "E588QtVUvresuXq2KoNEwAmoifCzYGpRBdHByN9KQMbi", diff --git a/rust/main/helm/hyperlane-agent/templates/external-secret.yaml b/rust/main/helm/hyperlane-agent/templates/external-secret.yaml index 9be0700b75..f3a6980d21 100644 --- a/rust/main/helm/hyperlane-agent/templates/external-secret.yaml +++ b/rust/main/helm/hyperlane-agent/templates/external-secret.yaml @@ -30,6 +30,14 @@ spec: {{- if eq .protocol "cosmos" }} HYP_CHAINS_{{ .name | upper }}_CUSTOMGRPCURLS: {{ printf "'{{ .%s_grpcs | mustFromJson | join \",\" }}'" .name }} {{- end }} + {{- if eq .protocol "sealevel" }} + {{- if eq ((.priorityFeeOracle).type) "helius" }} + HYP_CHAINS_{{ .name | upper }}_PRIORITYFEEORACLE_URL: {{ printf "'{{ .%s_helius }}'" .name }} + {{- end }} + {{- if eq ((.transactionSubmitter).url) "helius" }} + HYP_CHAINS_{{ .name | upper }}_TRANSACTIONSUBMITTER_URL: {{ printf "'{{ .%s_helius }}'" .name }} + {{- end }} + {{- end }} {{- end }} data: {{- /* @@ -45,4 +53,9 @@ spec: remoteRef: key: {{ printf "%s-grpc-endpoints-%s" $.Values.hyperlane.runEnv .name }} {{- end }} + {{- if and (eq .protocol "sealevel") (or (eq ((.priorityFeeOracle).type) "helius") (eq ((.transactionSubmitter).url) "helius")) }} + - secretKey: {{ printf "%s_helius" .name }} + remoteRef: + key: {{ printf "%s-rpc-endpoint-helius-%s" $.Values.hyperlane.runEnv .name }} + {{- end }} {{- end }} diff --git a/rust/main/helm/hyperlane-agent/templates/relayer-statefulset.yaml b/rust/main/helm/hyperlane-agent/templates/relayer-statefulset.yaml index da69543d8e..47be9ad2e9 100644 --- a/rust/main/helm/hyperlane-agent/templates/relayer-statefulset.yaml +++ b/rust/main/helm/hyperlane-agent/templates/relayer-statefulset.yaml @@ -17,7 +17,9 @@ spec: metadata: annotations: checksum/configmap: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/external-secret: {{ include (print $.Template.BasePath "/external-secret.yaml") . | sha256sum }} checksum/relayer-configmap: {{ include (print $.Template.BasePath "/relayer-configmap.yaml") . | sha256sum }} + checksum/relayer-external-secret: {{ include (print $.Template.BasePath "/relayer-external-secret.yaml") . | sha256sum }} {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} diff --git a/rust/main/helm/hyperlane-agent/templates/scraper-statefulset.yaml b/rust/main/helm/hyperlane-agent/templates/scraper-statefulset.yaml index 06326e260c..1b419e1123 100644 --- a/rust/main/helm/hyperlane-agent/templates/scraper-statefulset.yaml +++ b/rust/main/helm/hyperlane-agent/templates/scraper-statefulset.yaml @@ -17,6 +17,8 @@ spec: metadata: annotations: checksum/configmap: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/external-secret: {{ include (print $.Template.BasePath "/external-secret.yaml") . | sha256sum }} + checksum/scraper-external-secret: {{ include (print $.Template.BasePath "/scraper-external-secret.yaml") . | sha256sum }} {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} diff --git a/rust/main/helm/hyperlane-agent/templates/validator-statefulset.yaml b/rust/main/helm/hyperlane-agent/templates/validator-statefulset.yaml index 1b0a87dd41..b5929bfd99 100644 --- a/rust/main/helm/hyperlane-agent/templates/validator-statefulset.yaml +++ b/rust/main/helm/hyperlane-agent/templates/validator-statefulset.yaml @@ -17,7 +17,9 @@ spec: metadata: annotations: checksum/configmap: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/external-secret: {{ include (print $.Template.BasePath "/external-secret.yaml") . | sha256sum }} checksum/validator-configmap: {{ include (print $.Template.BasePath "/validator-configmap.yaml") . | sha256sum }} + checksum/scraper-external-secret: {{ include (print $.Template.BasePath "/scraper-external-secret.yaml") . | sha256sum }} {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} diff --git a/rust/main/hyperlane-base/src/settings/parser/connection_parser.rs b/rust/main/hyperlane-base/src/settings/parser/connection_parser.rs index 70e1b81835..b3f91ee88e 100644 --- a/rust/main/hyperlane-base/src/settings/parser/connection_parser.rs +++ b/rust/main/hyperlane-base/src/settings/parser/connection_parser.rs @@ -1,4 +1,7 @@ use eyre::eyre; +use hyperlane_sealevel::{ + HeliusPriorityFeeLevel, HeliusPriorityFeeOracleConfig, PriorityFeeOracleConfig, +}; use url::Url; use h_eth::TransactionOverrides; @@ -162,12 +165,24 @@ fn build_sealevel_connection_conf( chain: &ValueParser, err: &mut ConfigParsingError, operation_batch: OperationBatchConfig, -) -> h_sealevel::ConnectionConf { +) -> Option { + let mut local_err = ConfigParsingError::default(); + let native_token = parse_native_token(chain, err, 9); - h_sealevel::ConnectionConf { - url: url.clone(), - operation_batch, - native_token, + let priority_fee_oracle = parse_sealevel_priority_fee_oracle_config(chain, &mut local_err); + let transaction_submitter = parse_transaction_submitter_config(chain, &mut local_err); + + if !local_err.is_ok() { + err.merge(local_err); + None + } else { + Some(ChainConnectionConf::Sealevel(h_sealevel::ConnectionConf { + url: url.clone(), + operation_batch, + native_token, + priority_fee_oracle: priority_fee_oracle.unwrap(), + transaction_submitter: transaction_submitter.unwrap(), + })) } } @@ -196,6 +211,147 @@ fn parse_native_token( } } +fn parse_sealevel_priority_fee_oracle_config( + chain: &ValueParser, + err: &mut ConfigParsingError, +) -> Option { + let value_parser = chain.chain(err).get_opt_key("priorityFeeOracle").end(); + + let priority_fee_oracle = if let Some(value_parser) = value_parser { + let oracle_type = value_parser + .chain(err) + .get_key("type") + .parse_string() + .end() + .or_else(|| { + err.push( + &value_parser.cwp + "type", + eyre!("Missing priority fee oracle type"), + ); + None + }) + .unwrap_or_default(); + + match oracle_type { + "constant" => { + let fee = value_parser + .chain(err) + .get_key("fee") + .parse_u64() + .end() + .unwrap_or(0); + Some(PriorityFeeOracleConfig::Constant(fee)) + } + "helius" => { + let fee_level = parse_helius_priority_fee_level(&value_parser, err); + if !err.is_ok() { + return None; + } + let config = HeliusPriorityFeeOracleConfig { + url: value_parser + .chain(err) + .get_key("url") + .parse_from_str("Invalid url") + .end() + .unwrap(), + fee_level: fee_level.unwrap(), + }; + Some(PriorityFeeOracleConfig::Helius(config)) + } + _ => { + err.push( + &value_parser.cwp + "type", + eyre!("Unknown priority fee oracle type"), + ); + None + } + } + } else { + // If not specified at all, use default + Some(PriorityFeeOracleConfig::default()) + }; + + priority_fee_oracle +} + +fn parse_helius_priority_fee_level( + value_parser: &ValueParser, + err: &mut ConfigParsingError, +) -> Option { + let level = value_parser + .chain(err) + .get_opt_key("feeLevel") + .parse_string() + .end(); + + if let Some(level) = level { + match level.to_lowercase().as_str() { + "recommended" => Some(HeliusPriorityFeeLevel::Recommended), + "min" => Some(HeliusPriorityFeeLevel::Min), + "low" => Some(HeliusPriorityFeeLevel::Low), + "medium" => Some(HeliusPriorityFeeLevel::Medium), + "high" => Some(HeliusPriorityFeeLevel::High), + "veryhigh" => Some(HeliusPriorityFeeLevel::VeryHigh), + "unsafemax" => Some(HeliusPriorityFeeLevel::UnsafeMax), + _ => { + err.push( + &value_parser.cwp + "feeLevel", + eyre!("Unknown priority fee level"), + ); + None + } + } + } else { + // If not specified at all, use the default + Some(HeliusPriorityFeeLevel::default()) + } +} + +fn parse_transaction_submitter_config( + chain: &ValueParser, + err: &mut ConfigParsingError, +) -> Option { + let submitter_type = chain + .chain(err) + .get_opt_key("transactionSubmitter") + .get_opt_key("type") + .parse_string() + .end(); + + if let Some(submitter_type) = submitter_type { + match submitter_type.to_lowercase().as_str() { + "rpc" => { + let url = chain + .chain(err) + .get_opt_key("transactionSubmitter") + .get_opt_key("url") + .parse_from_str("Invalid url") + .end(); + Some(h_sealevel::TransactionSubmitterConfig::Rpc { url }) + } + "jito" => { + let url = chain + .chain(err) + .get_opt_key("transactionSubmitter") + .get_opt_key("url") + .parse_from_str("Invalid url") + .end(); + Some(h_sealevel::TransactionSubmitterConfig::Jito { url }) + } + _ => { + err.push( + &chain.cwp + "transactionSubmitter.type", + eyre!("Unknown transaction submitter type"), + ); + None + } + } + } else { + // If not specified at all, use default + Some(h_sealevel::TransactionSubmitterConfig::default()) + } +} + pub fn build_connection_conf( domain_protocol: HyperlaneDomainProtocol, rpcs: &[Url], @@ -216,14 +372,10 @@ pub fn build_connection_conf( .iter() .next() .map(|url| ChainConnectionConf::Fuel(h_fuel::ConnectionConf { url: url.clone() })), - HyperlaneDomainProtocol::Sealevel => rpcs.iter().next().map(|url| { - ChainConnectionConf::Sealevel(build_sealevel_connection_conf( - url, - chain, - err, - operation_batch, - )) - }), + HyperlaneDomainProtocol::Sealevel => rpcs + .iter() + .next() + .and_then(|url| build_sealevel_connection_conf(url, chain, err, operation_batch)), HyperlaneDomainProtocol::Cosmos => { build_cosmos_connection_conf(rpcs, chain, err, operation_batch) } diff --git a/typescript/infra/config/environments/mainnet3/agent.ts b/typescript/infra/config/environments/mainnet3/agent.ts index 3756344809..f81a40bea3 100644 --- a/typescript/infra/config/environments/mainnet3/agent.ts +++ b/typescript/infra/config/environments/mainnet3/agent.ts @@ -1,4 +1,10 @@ import { + AgentSealevelHeliusFeeLevel, + AgentSealevelPriorityFeeOracle, + AgentSealevelPriorityFeeOracleType, + AgentSealevelTransactionSubmitter, + AgentSealevelTransactionSubmitterType, + ChainName, GasPaymentEnforcement, GasPaymentEnforcementPolicyType, MatchingList, @@ -7,6 +13,7 @@ import { import { AgentChainConfig, + HELIUS_SECRET_URL_MARKER, RootAgentConfig, getAgentChainNamesFromConfig, } from '../../../src/config/agent/agent.js'; @@ -396,6 +403,43 @@ export const hyperlaneContextAgentChainNames = getAgentChainNamesFromConfig( mainnet3SupportedChainNames, ); +const sealevelPriorityFeeOracleConfigGetter = ( + chain: ChainName, +): AgentSealevelPriorityFeeOracle => { + // Special case for Solana mainnet + if (chain === 'solanamainnet') { + return { + type: AgentSealevelPriorityFeeOracleType.Helius, + feeLevel: AgentSealevelHeliusFeeLevel.Recommended, + // URL is auto populated by the external secrets in the helm chart + url: '', + }; + } + + // For all other chains, we use the constant fee oracle with a fee of 0 + return { + type: AgentSealevelPriorityFeeOracleType.Constant, + fee: '0', + }; +}; + +const sealevelTransactionSubmitterConfigGetter = ( + chain: ChainName, +): AgentSealevelTransactionSubmitter => { + // Special case for Solana mainnet + if (chain === 'solanamainnet') { + return { + type: AgentSealevelTransactionSubmitterType.Rpc, + url: HELIUS_SECRET_URL_MARKER, + }; + } + + // For all other chains, use the default RPC transaction submitter + return { + type: AgentSealevelTransactionSubmitterType.Rpc, + }; +}; + const contextBase = { namespace: environment, runEnv: environment, @@ -403,6 +447,10 @@ const contextBase = { aws: { region: 'us-east-1', }, + sealevel: { + priorityFeeOracleConfigGetter: sealevelPriorityFeeOracleConfigGetter, + transactionSubmitterConfigGetter: sealevelTransactionSubmitterConfigGetter, + }, } as const; const gasPaymentEnforcement: GasPaymentEnforcement[] = [ diff --git a/typescript/infra/src/agents/index.ts b/typescript/infra/src/agents/index.ts index b7be0e0098..24fccbac07 100644 --- a/typescript/infra/src/agents/index.ts +++ b/typescript/infra/src/agents/index.ts @@ -1,8 +1,14 @@ import fs from 'fs'; import { join } from 'path'; -import { ChainName, RelayerConfig, RpcConsensusType } from '@hyperlane-xyz/sdk'; -import { objOmitKeys } from '@hyperlane-xyz/utils'; +import { + AgentSealevelPriorityFeeOracle, + AgentSealevelTransactionSubmitter, + ChainName, + RelayerConfig, + RpcConsensusType, +} from '@hyperlane-xyz/sdk'; +import { ProtocolType, objOmitKeys } from '@hyperlane-xyz/utils'; import { Contexts } from '../../config/contexts.js'; import { getChain } from '../../config/registry.js'; @@ -87,12 +93,33 @@ export abstract class AgentHelmManager extends HelmManager if (reorgPeriod === undefined) { throw new Error(`No reorg period found for chain ${chain}`); } + + let priorityFeeOracle: AgentSealevelPriorityFeeOracle | undefined; + if (getChain(chain).protocol === ProtocolType.Sealevel) { + priorityFeeOracle = + this.config.rawConfig.sealevel?.priorityFeeOracleConfigGetter?.( + chain, + ); + } + + let transactionSubmitter: + | AgentSealevelTransactionSubmitter + | undefined; + if (getChain(chain).protocol === ProtocolType.Sealevel) { + transactionSubmitter = + this.config.rawConfig.sealevel?.transactionSubmitterConfigGetter?.( + chain, + ); + } + return { name: chain, rpcConsensusType: this.rpcConsensusType(chain), protocol: metadata.protocol, blocks: { reorgPeriod }, maxBatchSize: 32, + priorityFeeOracle, + transactionSubmitter, }; }), }, diff --git a/typescript/infra/src/config/agent/agent.ts b/typescript/infra/src/config/agent/agent.ts index 987a05f0ea..7d2dbe54b6 100644 --- a/typescript/infra/src/config/agent/agent.ts +++ b/typescript/infra/src/config/agent/agent.ts @@ -1,5 +1,7 @@ import { AgentChainMetadata, + AgentSealevelPriorityFeeOracle, + AgentSealevelTransactionSubmitter, AgentSignerAwsKey, AgentSignerKeyType, ChainName, @@ -85,8 +87,21 @@ export interface AgentContextConfig extends AgentEnvConfig { rolesWithKeys: Role[]; // Names of chains this context cares about (subset of environmentChainNames) contextChainNames: AgentChainNames; + sealevel?: SealevelAgentConfig; } +export interface SealevelAgentConfig { + priorityFeeOracleConfigGetter?: ( + chain: ChainName, + ) => AgentSealevelPriorityFeeOracle; + transactionSubmitterConfigGetter?: ( + chain: ChainName, + ) => AgentSealevelTransactionSubmitter; +} + +// An ugly way to mark a URL as a the secret Helius URL when Helm templating +export const HELIUS_SECRET_URL_MARKER = 'helius'; + // incomplete common agent configuration for a role interface AgentRoleConfig { // K8s-specific diff --git a/typescript/infra/src/utils/helm.ts b/typescript/infra/src/utils/helm.ts index 855393473a..4ed4cb478f 100644 --- a/typescript/infra/src/utils/helm.ts +++ b/typescript/infra/src/utils/helm.ts @@ -13,12 +13,13 @@ export enum HelmCommand { } export function helmifyValues(config: any, prefix?: string): string[] { + if (config === null || config === undefined) { + return []; + } + if (typeof config !== 'object') { // Helm incorrectly splits on unescaped commas. - const value = - config !== undefined - ? JSON.stringify(config).replaceAll(',', '\\,') - : undefined; + const value = JSON.stringify(config).replaceAll(',', '\\,'); return [`--set ${prefix}=${value}`]; } diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts index dd94504fcd..87e524110e 100644 --- a/typescript/sdk/src/index.ts +++ b/typescript/sdk/src/index.ts @@ -209,6 +209,12 @@ export { AgentCosmosGasPrice, AgentLogFormat, AgentLogLevel, + AgentSealevelChainMetadata, + AgentSealevelHeliusFeeLevel, + AgentSealevelPriorityFeeOracle, + AgentSealevelPriorityFeeOracleType, + AgentSealevelTransactionSubmitter, + AgentSealevelTransactionSubmitterType, AgentSigner, AgentSignerAwsKey, AgentSignerHexKey, diff --git a/typescript/sdk/src/metadata/agentConfig.ts b/typescript/sdk/src/metadata/agentConfig.ts index cb570e29eb..9ff999c0dc 100644 --- a/typescript/sdk/src/metadata/agentConfig.ts +++ b/typescript/sdk/src/metadata/agentConfig.ts @@ -51,6 +51,26 @@ export enum AgentSignerKeyType { Cosmos = 'cosmosKey', } +export enum AgentSealevelPriorityFeeOracleType { + Helius = 'helius', + Constant = 'constant', +} + +export enum AgentSealevelHeliusFeeLevel { + Recommended = 'recommended', + Min = 'min', + Low = 'low', + Medium = 'medium', + High = 'high', + VeryHigh = 'veryHigh', + UnsafeMax = 'unsafeMax', +} + +export enum AgentSealevelTransactionSubmitterType { + Rpc = 'rpc', + Jito = 'jito', +} + const AgentSignerHexKeySchema = z .object({ type: z.literal(AgentSignerKeyType.Hex).optional(), @@ -120,6 +140,40 @@ export type AgentCosmosGasPrice = z.infer< typeof AgentCosmosChainMetadataSchema >['gasPrice']; +const AgentSealevelChainMetadataSchema = z.object({ + priorityFeeOracle: z + .union([ + z.object({ + type: z.literal(AgentSealevelPriorityFeeOracleType.Helius), + url: z.string(), + // TODO add options + feeLevel: z.nativeEnum(AgentSealevelHeliusFeeLevel), + }), + z.object({ + type: z.literal(AgentSealevelPriorityFeeOracleType.Constant), + // In microlamports + fee: ZUWei, + }), + ]) + .optional(), + transactionSubmitter: z + .object({ + type: z.nativeEnum(AgentSealevelTransactionSubmitterType), + url: z.string().optional(), + }) + .optional(), +}); + +export type AgentSealevelChainMetadata = z.infer< + typeof AgentSealevelChainMetadataSchema +>; + +export type AgentSealevelPriorityFeeOracle = + AgentSealevelChainMetadata['priorityFeeOracle']; + +export type AgentSealevelTransactionSubmitter = + AgentSealevelChainMetadata['transactionSubmitter']; + export const AgentChainMetadataSchema = ChainMetadataSchemaObject.merge( HyperlaneDeploymentArtifactsSchema, ) @@ -155,6 +209,7 @@ export const AgentChainMetadataSchema = ChainMetadataSchemaObject.merge( .optional(), }) .merge(AgentCosmosChainMetadataSchema.partial()) + .merge(AgentSealevelChainMetadataSchema.partial()) .refine((metadata) => { // Make sure that the signer is valid for the protocol @@ -201,6 +256,13 @@ export const AgentChainMetadataSchema = ChainMetadataSchemaObject.merge( } } + // If the protocol type is Sealevel, require everything in AgentSealevelChainMetadataSchema + if (metadata.protocol === ProtocolType.Sealevel) { + if (!AgentSealevelChainMetadataSchema.safeParse(metadata).success) { + return false; + } + } + return true; });