diff --git a/auction-server/Cargo.lock b/auction-server/Cargo.lock index bd1cd087..be59d7e3 100644 --- a/auction-server/Cargo.lock +++ b/auction-server/Cargo.lock @@ -676,6 +676,7 @@ dependencies = [ "axum-streams", "base64 0.22.1", "bincode", + "borsh 1.5.1", "clap 4.5.18", "email_address", "ethers", diff --git a/auction-server/Cargo.toml b/auction-server/Cargo.toml index b93296a3..ff07f4a9 100644 --- a/auction-server/Cargo.toml +++ b/auction-server/Cargo.toml @@ -50,6 +50,7 @@ anchor-lang = "0.30.1" express-relay = { path = "../contracts/svm/programs/express_relay" } solana-rpc-client = "2.0.13" solana-transaction-status = "2.0.13" +borsh = "1.5.1" # The curve25519-dalek crate is a dependency of solana-sdk. # This crate relies on a specific version of zeroize that is incompatible with many other packages. diff --git a/auction-server/src/auction.rs b/auction-server/src/auction.rs index 7bb13651..92911c7f 100644 --- a/auction-server/src/auction.rs +++ b/auction-server/src/auction.rs @@ -37,6 +37,7 @@ use { ChainStoreEvm, ChainStoreSvm, LookupTableCache, + MicroLamports, SimulatedBidCoreFields, SimulatedBidEvm, SimulatedBidSvm, @@ -60,6 +61,7 @@ use { axum::async_trait, axum_prometheus::metrics, bincode::serialized_size, + borsh::de::BorshDeserialize, ethers::{ abi, contract::{ @@ -124,6 +126,7 @@ use { solana_sdk::{ address_lookup_table::state::AddressLookupTable, commitment_config::CommitmentConfig, + compute_budget, instruction::CompiledInstruction, packet::PACKET_DATA_SIZE, pubkey::Pubkey, @@ -1152,6 +1155,11 @@ pub async fn handle_bid_svm( .ok_or(RestError::InvalidChainId)? .as_ref(); + let compute_budget = chain_store + .get_minimum_acceptable_prioritization_fee() + .await + .unwrap_or(0); + check_compute_budget(compute_budget, &bid.transaction)?; let bid_data_svm = extract_bid_data_svm(chain_store, bid.transaction.clone(), &chain_store.client).await?; @@ -1235,6 +1243,50 @@ fn all_signature_exists_svm( Ok(()) } +fn check_compute_budget( + compute_budget: MicroLamports, + transaction: &VersionedTransaction, +) -> Result<(), RestError> { + let budgets: Vec = transaction + .message + .instructions() + .iter() + .filter_map(|instruction| { + let program_id = instruction.program_id(transaction.message.static_account_keys()); + if program_id != &compute_budget::id() { + return None; + } + + match compute_budget::ComputeBudgetInstruction::try_from_slice(&instruction.data) { + Ok(compute_budget::ComputeBudgetInstruction::SetComputeUnitPrice(price)) => { + Some(price) + } + _ => None, + } + }) + .collect(); + if budgets.len() > 1 { + return Err(RestError::BadParameters( + "Multiple SetComputeUnitPrice instructions".to_string(), + )); + } + if budgets.is_empty() && compute_budget > 0 { + return Err(RestError::BadParameters(format!( + "No SetComputeUnitPrice instruction. Minimum compute budget is {}", + compute_budget + ))); + } + if let Some(budget) = budgets.first() { + if *budget < compute_budget { + return Err(RestError::BadParameters(format!( + "Compute budget is too low. Minimum compute budget is {}", + compute_budget + ))); + } + } + Ok(()) +} + async fn verify_signatures_svm( store_new: Arc, chain_store: &ChainStoreSvm, diff --git a/auction-server/src/state.rs b/auction-server/src/state.rs index 8795a5ce..d191092f 100644 --- a/auction-server/src/state.rs +++ b/auction-server/src/state.rs @@ -120,14 +120,17 @@ use { time::Duration, }, time::UtcOffset, - tokio::sync::{ - broadcast::{ - self, - Receiver, - Sender, + tokio::{ + sync::{ + broadcast::{ + self, + Receiver, + Sender, + }, + Mutex, + RwLock, }, - Mutex, - RwLock, + time::Instant, }, tokio_util::task::TaskTracker, utoipa::{ @@ -559,6 +562,14 @@ impl ChainStoreEvm { } } +pub type MicroLamports = u64; +#[derive(Clone, Debug)] +struct PrioritizationFeeSample { + ///micro-lamports per compute unit. + fee: MicroLamports, + sample_time: Instant, +} + pub struct ChainStoreSvm { pub core_fields: ChainStoreCoreFields, @@ -566,14 +577,13 @@ pub struct ChainStoreSvm { log_sender: Sender>, // only to avoid closing the channel _dummy_log_receiver: Receiver>, + recent_prioritization_fees: RwLock>, pub client: RpcClient, pub config: ConfigSvm, pub express_relay_svm: ExpressRelaySvm, pub wallet_program_router_account: Pubkey, pub name: String, pub lookup_table_cache: LookupTableCache, - /// Recent network prioritization fees in micro-lamports per compute unit. - pub recent_prioritization_fees: RwLock>, } const SVM_SEND_TRANSACTION_RETRY_COUNT: i32 = 5; @@ -736,16 +746,42 @@ impl ChainStoreSvm { /// Polls an estimate of recent priotization fees and stores it in `recent_prioritization_fees`. /// `recent_prioritization_fees` stores the last 12 estimates received. - pub async fn get_and_store_recent_prioritization_fee(&self) -> Result { + pub async fn get_and_store_recent_prioritization_fee( + &self, + ) -> Result { let fee = self.get_median_prioritization_fee().await?; let mut write_guard = self.recent_prioritization_fees.write().await; - write_guard.push_back(fee); + let sample = PrioritizationFeeSample { + fee, + sample_time: Instant::now(), + }; + tracing::info!("Last prioritization fee: {:?}", sample); + write_guard.push_back(sample); if write_guard.len() > 12 { write_guard.pop_front(); } - tracing::info!("Recent prioritization fees: {:?}", write_guard); Ok(fee) } + + /// Get the minimum prioritization fee that is acceptable for submission on chain. + /// In order to avoid rejection of transactions because of recent changes in the priority fees, + /// we consider the minimum priority fee that was acceptable in the last 15 seconds. + /// This timeframe should include at least 2 samples, otherwise we will reject bids if the + /// latest sample is higher than the previous one and the searchers have not updated their + /// priority fees fast enough. + pub async fn get_minimum_acceptable_prioritization_fee(&self) -> Option { + let budgets = self.recent_prioritization_fees.read().await; + budgets + .iter() + .filter_map(|b| { + if b.sample_time.elapsed() < Duration::from_secs(15) { + Some(b.fee) + } else { + None + } + }) + .min() + } } pub type BidId = Uuid; @@ -1644,7 +1680,9 @@ impl Store { #[serde_as] #[derive(Serialize, Clone, ToSchema, ToResponse)] pub struct SvmChainUpdate { - pub chain_id: ChainId, + pub chain_id: ChainId, #[serde_as(as = "DisplayFromStr")] - pub blockhash: Hash, + pub blockhash: Hash, + /// The prioritization fee that the server suggests to use for the next transaction + pub latest_prioritization_fee: MicroLamports, } diff --git a/auction-server/src/watcher.rs b/auction-server/src/watcher.rs index bc8781b9..fe10c216 100644 --- a/auction-server/src/watcher.rs +++ b/auction-server/src/watcher.rs @@ -50,10 +50,11 @@ pub async fn run_watcher_loop_svm(store: Arc, chain_id: String) -> Result .await, chain_store.get_and_store_recent_prioritization_fee().await, ) { - (Ok(result), Ok(_fee)) => { + (Ok(result), Ok(fee)) => { store.broadcast_svm_chain_update(SvmChainUpdate { - chain_id: chain_id.clone(), - blockhash: result.0, + chain_id: chain_id.clone(), + blockhash: result.0, + latest_prioritization_fee: fee, }); } (Err(e), _) => { diff --git a/sdk/js/package-lock.json b/sdk/js/package-lock.json index 902eff68..dc40f743 100644 --- a/sdk/js/package-lock.json +++ b/sdk/js/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pythnetwork/express-relay-js", - "version": "0.14.0", + "version": "0.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pythnetwork/express-relay-js", - "version": "0.14.0", + "version": "0.14.1", "license": "Apache-2.0", "dependencies": { "@coral-xyz/anchor": "^0.30.1", diff --git a/sdk/js/package.json b/sdk/js/package.json index 3c41ec62..102ef81d 100644 --- a/sdk/js/package.json +++ b/sdk/js/package.json @@ -1,6 +1,6 @@ { "name": "@pythnetwork/express-relay-js", - "version": "0.14.0", + "version": "0.14.1", "description": "Utilities for interacting with the express relay protocol", "homepage": "https://github.com/pyth-network/per/tree/main/sdk/js", "author": "Douro Labs", diff --git a/sdk/js/src/examples/simpleSearcherLimo.ts b/sdk/js/src/examples/simpleSearcherLimo.ts index 68426c39..70ba64e9 100644 --- a/sdk/js/src/examples/simpleSearcherLimo.ts +++ b/sdk/js/src/examples/simpleSearcherLimo.ts @@ -20,8 +20,8 @@ import { Keypair, PublicKey, Connection, - Blockhash, TransactionInstruction, + ComputeBudgetProgram, } from "@solana/web3.js"; import * as limo from "@kamino-finance/limo-sdk"; @@ -39,7 +39,7 @@ export class SimpleSearcherLimo { protected readonly connectionSvm: Connection; protected mintDecimals: Record = {}; protected expressRelayConfig: ExpressRelaySvmConfig | undefined; - protected recentBlockhash: Record = {}; + protected latestChainUpdate: Record = {}; protected readonly bid: anchor.BN; constructor( public endpointExpressRelay: string, @@ -101,7 +101,14 @@ export class SimpleSearcherLimo { ); const ixsTakeOrder = await this.generateTakeOrderIxs(limoClient, order); - const txRaw = new anchor.web3.Transaction().add(...ixsTakeOrder); + const feeInstruction = ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: + this.latestChainUpdate[this.chainId].latest_prioritization_fee, + }); + const txRaw = new anchor.web3.Transaction().add( + feeInstruction, + ...ixsTakeOrder + ); const bidAmount = await this.getBidAmount(order); @@ -118,7 +125,8 @@ export class SimpleSearcherLimo { config.feeReceiverRelayer ); - bid.transaction.recentBlockhash = this.recentBlockhash[this.chainId]; + bid.transaction.recentBlockhash = + this.latestChainUpdate[this.chainId].blockhash; bid.transaction.sign(this.searcher); return bid; } @@ -208,7 +216,7 @@ export class SimpleSearcherLimo { } async opportunityHandler(opportunity: Opportunity) { - if (!this.recentBlockhash[this.chainId]) { + if (!this.latestChainUpdate[this.chainId]) { console.log( `No recent blockhash for chain ${this.chainId}, skipping bid` ); @@ -228,7 +236,7 @@ export class SimpleSearcherLimo { } async svmChainUpdateHandler(update: SvmChainUpdate) { - this.recentBlockhash[update.chain_id] = update.blockhash; + this.latestChainUpdate[update.chain_id] = update; } // NOTE: Developers are responsible for implementing custom removal logic specific to their use case. diff --git a/sdk/js/src/serverTypes.d.ts b/sdk/js/src/serverTypes.d.ts index ec847c28..c55805c7 100644 --- a/sdk/js/src/serverTypes.d.ts +++ b/sdk/js/src/serverTypes.d.ts @@ -26,7 +26,7 @@ export interface paths { * Fetch opportunities ready for execution or historical opportunities * @description depending on the mode. You need to provide `chain_id` for historical mode. * Opportunities are sorted by creation time in ascending order. - * Total number of opportunities returned is limited by 20. + * Total number of opportunities returned is capped by the server to preserve bandwidth. */ get: operations["get_opportunities"]; /** @@ -773,6 +773,7 @@ export interface components { SvmChainUpdate: { blockhash: components["schemas"]["Hash"]; chain_id: components["schemas"]["ChainId"]; + latest_prioritization_fee: components["schemas"]["MicroLamports"]; }; TokenAmountEvm: { /** @@ -926,7 +927,7 @@ export interface operations { * Fetch opportunities ready for execution or historical opportunities * @description depending on the mode. You need to provide `chain_id` for historical mode. * Opportunities are sorted by creation time in ascending order. - * Total number of opportunities returned is limited by 20. + * Total number of opportunities returned is capped by the server to preserve bandwidth. */ get_opportunities: { parameters: { @@ -946,7 +947,7 @@ export interface operations { */ from_time?: string | null; /** - * @description The maximum number of opportunities to return. Capped at 100. + * @description The maximum number of opportunities to return. Capped at 100; if more than 100 requested, at most 100 will be returned. * @example 20 */ limit?: number; diff --git a/sdk/python/express_relay/models/svm.py b/sdk/python/express_relay/models/svm.py index 67b2aa31..3896d738 100644 --- a/sdk/python/express_relay/models/svm.py +++ b/sdk/python/express_relay/models/svm.py @@ -324,6 +324,7 @@ class SvmChainUpdate(BaseModel): """ chain_id: str blockhash: SvmHash + latest_prioritization_fee: int class ProgramSvm(Enum): diff --git a/sdk/python/express_relay/searcher/examples/simple_searcher_svm.py b/sdk/python/express_relay/searcher/examples/simple_searcher_svm.py index 515d33d7..b9fcf0bd 100644 --- a/sdk/python/express_relay/searcher/examples/simple_searcher_svm.py +++ b/sdk/python/express_relay/searcher/examples/simple_searcher_svm.py @@ -7,6 +7,7 @@ from solana.rpc.async_api import AsyncClient from solana.rpc.commitment import Finalized +from solders.compute_budget import set_compute_unit_price from solders.instruction import Instruction from solders.keypair import Keypair from solders.pubkey import Pubkey @@ -33,7 +34,7 @@ class SimpleSearcherSvm: express_relay_metadata: ExpressRelayMetadata | None mint_decimals_cache: typing.Dict[str, int] - recent_blockhash: typing.Dict[str, SvmHash] + latest_chain_update: typing.Dict[str, SvmChainUpdate] def __init__( self, @@ -62,7 +63,7 @@ def __init__( self.limo_client = LimoClient(self.rpc_client) self.express_relay_metadata = None self.mint_decimals_cache = {} - self.recent_blockhash = {} + self.latest_chain_update = {} self.logger = logging.getLogger('searcher') self.setup_logger() @@ -85,7 +86,7 @@ async def opportunity_callback(self, opp: Opportunity): Args: opp: An object representing a single opportunity. """ - if opp.chain_id not in self.recent_blockhash: + if opp.chain_id not in self.latest_chain_update: self.logger.info(f"No recent blockhash for chain, {opp.chain_id} skipping bid") return None @@ -153,11 +154,13 @@ async def assess_opportunity(self, opp: OpportunitySvm) -> BidSvm | None: fee_receiver_relayer=(await self.get_metadata()).fee_receiver_relayer, relayer_signer=(await self.get_metadata()).relayer_signer, ) + latest_chain_update = self.latest_chain_update[self.chain_id] + fee_instruction = set_compute_unit_price(latest_chain_update.latest_prioritization_fee) transaction = Transaction.new_with_payer( - [submit_bid_ix] + ixs_take_order, self.private_key.pubkey() + [fee_instruction, submit_bid_ix] + ixs_take_order, self.private_key.pubkey() ) transaction.partial_sign( - [self.private_key], recent_blockhash=self.recent_blockhash[self.chain_id] + [self.private_key], recent_blockhash=latest_chain_update.blockhash ) bid = BidSvm(transaction=transaction, chain_id=self.chain_id) return bid @@ -224,7 +227,7 @@ async def get_bid_amount(self, order: OrderStateAndAddress) -> int: return self.bid_amount async def svm_chain_update_callback(self, svm_chain_update: SvmChainUpdate): - self.recent_blockhash[svm_chain_update.chain_id] = svm_chain_update.blockhash + self.latest_chain_update[svm_chain_update.chain_id] = svm_chain_update # NOTE: Developers are responsible for implementing custom removal logic specific to their use case. async def remove_opportunities_callback( diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 334a21fc..d5498880 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "express-relay" -version = "0.13.4" +version = "0.13.5" description = "Utilities for searchers and protocols to interact with the Express Relay protocol." authors = ["dourolabs"] license = "Apache-2.0"