diff --git a/Cargo.lock b/Cargo.lock index 3f4a3c6bb..fd021c30f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -280,7 +280,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -291,7 +291,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -729,6 +729,20 @@ dependencies = [ "tower-service", ] +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "futures-core", + "getrandom 0.2.17", + "instant", + "pin-project-lite", + "rand 0.8.6", + "tokio", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -2960,6 +2974,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3700,6 +3723,7 @@ name = "magicblock-committor-service" version = "0.13.4" dependencies = [ "async-trait", + "backoff", "bincode", "borsh", "futures-util", @@ -3714,6 +3738,7 @@ dependencies = [ "magicblock-program", "magicblock-rpc-client", "magicblock-table-mania", + "pin-project", "rand 0.9.4", "rusqlite", "solana-account 3.4.0", @@ -3738,6 +3763,7 @@ dependencies = [ "test-kit", "thiserror 2.0.18", "tokio", + "tokio-stream", "tokio-util", "tracing", ] @@ -3871,6 +3897,7 @@ dependencies = [ "bincode", "const-crypto", "serde", + "solana-hash 4.2.0", "solana-program 2.3.0", "solana-program 3.0.0", "solana-signature 2.3.0", @@ -4402,7 +4429,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5217,7 +5244,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -6247,7 +6274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9af15651c..16b38f9f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ agave-geyser-plugin-interface = { version = "4.0" } agave-precompiles = { version = "4.0", features = ["agave-unstable-api"] } agave-syscalls = { version = "=4.0.0", features = ["agave-unstable-api"] } anyhow = "1.0.86" +backoff = { version = "0.4", features = ["tokio"] } arc-swap = { version = "1.7" } assert_matches = "1.5.0" async-nats = "0.46" @@ -137,6 +138,7 @@ num-traits = "0.2" num_cpus = "1.16.0" parking_lot = "0.12" paste = "1.0" +pin-project = "1.1.13" prometheus = "0.13.4" # Keep in sync with `solana-storage-proto` codegen. diff --git a/magicblock-api/src/magic_sys_adapter.rs b/magicblock-api/src/magic_sys_adapter.rs index 270cd0b20..fae814bbc 100644 --- a/magicblock-api/src/magic_sys_adapter.rs +++ b/magicblock-api/src/magic_sys_adapter.rs @@ -2,7 +2,8 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use magicblock_committor_service::{ committor_processor::CommittorProcessor, - intent_executor::task_info_fetcher::TaskInfoFetcherResult, + intent_engine::db::DummyIntentBacklog, + tasks::task_info_fetcher::TaskInfoFetcherResult, }; use magicblock_core::{intent::CommittedAccount, traits::MagicSys}; use magicblock_metrics::metrics; @@ -13,7 +14,7 @@ use tracing::error; #[derive(Clone)] pub struct MagicSysAdapter { handle: tokio::runtime::Handle, - committor_processor: Arc, + committor_processor: Arc>, } impl MagicSysAdapter { @@ -28,7 +29,7 @@ impl MagicSysAdapter { pub fn new( handle: tokio::runtime::Handle, - committor_processor: Arc, + committor_processor: Arc>, ) -> Self { Self { handle, diff --git a/magicblock-api/src/magic_validator.rs b/magicblock-api/src/magic_validator.rs index b4d569ab8..7ac35ad1b 100644 --- a/magicblock-api/src/magic_validator.rs +++ b/magicblock-api/src/magic_validator.rs @@ -19,10 +19,11 @@ use magicblock_chainlink::{ ProdInnerChainlink, }; use magicblock_committor_service::{ - committor_processor::CommittorProcessor, - config::ChainConfig, - service::{intent_client::InternalIntentRpcClient, IntentExecutionService}, - ComputeBudgetConfig, DEFAULT_ACTIONS_TIMEOUT, + committor_processor::CommittorProcessor, config::ChainConfig, + intent_engine::db::DummyIntentBacklog, + outbox::outbox_client::InternalOutboxClient, + service::IntentExecutionService, ComputeBudgetConfig, + DEFAULT_ACTIONS_TIMEOUT, }; use magicblock_config::{ config::{ @@ -98,8 +99,12 @@ type InnerChainlinkImpl = ProdInnerChainlink; type ChainlinkImpl = ProdChainlink; -type IntentExecutionServiceImpl = - IntentExecutionService>; +type CommittorProcessorImpl = CommittorProcessor; + +type IntentExecutionServiceImpl = IntentExecutionService< + InternalOutboxClient, + DummyIntentBacklog, +>; // ----------------- // MagicValidator @@ -241,22 +246,31 @@ impl MagicValidator { log_timing("startup", "chainlink_init", step_start); let step_start = Instant::now(); + let outbox_client = { + let val = Self::init_outbox_client( + &config, + &accountsdb, + &dispatch.transaction_scheduler, + ledger.latest_block(), + ); + Arc::new(val) + }; let committor_processor = { let processor = Self::init_committor_processor( &config, ledger.latest_block(), + &accountsdb, + &outbox_client, &shared_chain_slot, - )?; + ); Arc::new(processor) }; - let intent_execution_service = Self::init_intent_execution_service( - &accountsdb, - &chainlink, - &dispatch.transaction_scheduler, - ledger.latest_block(), - &committor_processor, + let intent_execution_service = IntentExecutionServiceImpl::new( + chainlink.clone(), + outbox_client.clone(), + committor_processor.clone(), config.ledger.block_time, - &token, + token.clone(), ); log_timing("startup", "committor_service_init", step_start); init_magic_sys(Arc::new(MagicSysAdapter::new( @@ -461,14 +475,29 @@ impl MagicValidator { }) } + fn init_outbox_client( + config: &ValidatorParams, + accounts_db: &Arc, + transaction_scheduler: &TransactionSchedulerHandle, + latest_block: &LatestBlock, + ) -> InternalOutboxClient { + let rpc_client = RpcClient::new(config.aperture.listen.http()); + InternalOutboxClient::new( + accounts_db.clone(), + Arc::new(rpc_client), + transaction_scheduler.clone(), + latest_block.clone(), + ) + } + pub fn init_committor_processor( config: &ValidatorParams, latest_block: &LatestBlock, + accounts_db: &Arc, + outbox_client: &Arc>, shared_chain_slot: &Option>, - ) -> ApiResult { + ) -> CommittorProcessorImpl { let authority = config.validator.keypair.insecure_clone(); - let committor_persist_path = - config.storage.join("committor_service.sqlite"); let base_chain_config = ChainConfig { rpc_uri: config.rpc_url().to_owned(), commitment: CommitmentConfig::confirmed(), @@ -488,36 +517,13 @@ impl MagicValidator { config.validator.keypair.insecure_clone(), latest_block.clone(), ); - Ok(CommittorProcessor::try_new( + CommittorProcessor::new( authority, - committor_persist_path, base_chain_config, shared_chain_slot.clone(), + DummyIntentBacklog::new(accounts_db.clone()), + outbox_client.clone(), actions_callback_executor, - )?) - } - - fn init_intent_execution_service( - accounts_db: &Arc, - chainlink: &Arc, - transaction_scheduler: &TransactionSchedulerHandle, - latest_block: &LatestBlock, - committor_processor: &Arc, - slot_interval: Duration, - cancellation_token: &CancellationToken, - ) -> IntentExecutionServiceImpl { - let intent_client = InternalIntentRpcClient::new( - accounts_db.clone(), - transaction_scheduler.clone(), - latest_block.clone(), - ); - - IntentExecutionServiceImpl::new( - chainlink.clone(), - intent_client, - committor_processor.clone(), - slot_interval, - cancellation_token.clone(), ) } diff --git a/magicblock-committor-service/Cargo.toml b/magicblock-committor-service/Cargo.toml index d9728d739..f59f290fe 100644 --- a/magicblock-committor-service/Cargo.toml +++ b/magicblock-committor-service/Cargo.toml @@ -12,6 +12,7 @@ doctest = false [dependencies] async-trait = { workspace = true } +backoff = { workspace = true } bincode = { workspace = true } borsh = { workspace = true } futures-util = { workspace = true } @@ -32,6 +33,7 @@ magicblock-metrics = { workspace = true } magicblock-program = { workspace = true } magicblock-rpc-client = { workspace = true } magicblock-table-mania = { workspace = true } +pin-project = { workspace = true } rusqlite = { workspace = true } solana-account = { workspace = true } solana-account-decoder = { workspace = true } @@ -54,8 +56,10 @@ solana-transaction-status-client-types = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } +tokio-stream = { workspace = true } [dev-dependencies] +magicblock-program = { workspace = true, features = ["dev-context-only-utils"] } solana-signature = { workspace = true, features = ["rand"] } rand = { workspace = true } tempfile = { workspace = true } @@ -63,3 +67,4 @@ test-kit = { workspace = true } [features] default = [] +dev-context-only-utils = [] \ No newline at end of file diff --git a/magicblock-committor-service/src/committor_processor.rs b/magicblock-committor-service/src/committor_processor.rs index b610480f7..98416d4ef 100644 --- a/magicblock-committor-service/src/committor_processor.rs +++ b/magicblock-committor-service/src/committor_processor.rs @@ -1,67 +1,60 @@ use std::{ collections::{hash_map::Entry, HashMap}, - path::Path, sync::{atomic::AtomicU64, Arc, Mutex}, - time::{SystemTime, UNIX_EPOCH}, }; use futures_util::future::join_all; use magicblock_core::traits::ActionsCallbackScheduler; -use magicblock_program::magic_scheduled_base_intent::ScheduledIntentBundle; +use magicblock_program::outbox_intent_bundles::OutboxIntentBundle; use magicblock_rpc_client::MagicblockRpcClient; use magicblock_table_mania::{GarbageCollectorConfig, TableMania}; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_rpc_client::nonblocking::rpc_client::RpcClient; -use solana_signer::Signer; use tokio::sync::{broadcast, oneshot, oneshot::error::RecvError}; use tracing::{error, info, instrument}; use crate::{ config::ChainConfig, error::{CommittorServiceError, CommittorServiceResult}, - intent_execution_manager::{ - db::DummyDB, BroadcastedIntentExecutionResult, IntentExecutionManager, + intent_engine::{ + db::BacklogDB, BroadcastedIntentExecutionResult, IntentEngineHandle, }, intent_executor::{ - intent_executor_factory::ExecutorConfig, - task_info_fetcher::{ - CacheTaskInfoFetcher, RpcTaskInfoFetcher, TaskInfoFetcher, - TaskInfoFetcherResult, - }, + error::IntentExecutorError, intent_executor_factory::ExecutorConfig, }, - persist::{ - CommitStatusRow, IntentPersister, IntentPersisterImpl, - MessageSignatures, + outbox::OutboxClient, + tasks::task_info_fetcher::{ + CacheTaskInfoFetcher, RpcTaskInfoFetcher, TaskInfoFetcher, + TaskInfoFetcherResult, }, }; + const POISONED_MUTEX_MSG: &str = "CommittorProcessor pending messages mutex poisoned!"; -pub(crate) const RECOVERY_MAX_AGE_SECS: u64 = 14 * 24 * 60 * 60; type BundleResultListener = oneshot::Sender; -pub struct CommittorProcessor { - authority: Keypair, +pub struct CommittorProcessor { _table_mania: TableMania, - magic_rpc_client: MagicblockRpcClient, - persister: IntentPersisterImpl, - commits_scheduler: IntentExecutionManager, + commits_scheduler: IntentEngineHandle, task_info_fetcher: Arc>, pending_result_listeners: Arc>>, } -impl CommittorProcessor { - pub fn try_new( +impl CommittorProcessor { + pub fn new( authority: Keypair, - persist_file: P, chain_config: ChainConfig, chain_slot: Option>, + db: D, + outbox_client: Arc, actions_callback_executor: A, - ) -> CommittorServiceResult + ) -> Self where - P: AsRef, A: ActionsCallbackScheduler, + O: OutboxClient, + O::Error: Into, { let rpc_client = RpcClient::new_with_commitment( chain_config.rpc_uri.to_string(), @@ -94,18 +87,15 @@ impl CommittorProcessor { Some(gc_config), ); - // Create commit persister - let persister = IntentPersisterImpl::try_new(persist_file)?; - // Create commit scheduler let task_info_fetcher = Arc::new(CacheTaskInfoFetcher::new( RpcTaskInfoFetcher::new(magic_block_rpc_client.clone()), )); - let commits_scheduler = IntentExecutionManager::new( + let commits_scheduler = IntentEngineHandle::new( magic_block_rpc_client.clone(), - DummyDB::new(), + db, task_info_fetcher.clone(), - Some(persister.clone()), + outbox_client, table_mania.clone(), ExecutorConfig { compute_budget_config: chain_config @@ -123,116 +113,19 @@ impl CommittorProcessor { pending_result_listeners.clone(), )); - Ok(Self { - authority, + Self { _table_mania: table_mania, - magic_rpc_client: magic_block_rpc_client, commits_scheduler, - persister, task_info_fetcher, pending_result_listeners, - }) - } - - pub fn get_commit_statuses( - &self, - message_id: u64, - ) -> CommittorServiceResult> { - let commit_statuses = - self.persister.get_commit_statuses_by_message(message_id)?; - Ok(commit_statuses) - } - - pub fn get_commit_signature( - &self, - commit_id: u64, - pubkey: Pubkey, - ) -> CommittorServiceResult> { - let signatures = self - .persister - .get_signatures_by_commit(commit_id, &pubkey)?; - Ok(signatures) - } - - /// Fetches pending bundles from DB - pub async fn pending_intent_bundles( - &self, - ) -> CommittorServiceResult> { - // Extract pending bundles satisfying predicate - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let mut bundles = self.persister.pending_intent_bundles( - now.saturating_sub(RECOVERY_MAX_AGE_SECS), - )?; - - if bundles.is_empty() { - return Ok(bundles); - } - - // Log info about extracted bundles - { - let accounts_count: usize = bundles - .iter() - .map(|bundle| bundle.get_all_committed_pubkeys().len()) - .sum(); - info!( - intent_count = bundles.len(), - accounts_count, - "Loaded pending commit intents from persistence for recovery" - ); } - - // Extracted bundles are out of data and missing some of the info - self.refresh_intent_bundles(&mut bundles).await?; - - Ok(bundles) - } - - async fn refresh_intent_bundles( - &self, - intent_bundles: &mut [ScheduledIntentBundle], - ) -> CommittorServiceResult<()> { - let payer = self.authority.pubkey(); - let slot = self.magic_rpc_client.get_slot().await?; - - macro_rules! set_remote_slot { - ($field:expr, $slot:expr) => { - if let Some(ref mut v) = $field { - v.get_committed_accounts_mut() - .iter_mut() - .for_each(|a| a.remote_slot = slot); - } - }; - } - - intent_bundles.iter_mut().for_each(|b| { - b.payer = payer; - set_remote_slot!(b.intent_bundle.commit, slot); - set_remote_slot!(b.intent_bundle.commit_finalize, slot); - set_remote_slot!(b.intent_bundle.commit_and_undelegate, slot); - set_remote_slot!( - b.intent_bundle.commit_finalize_and_undelegate, - slot - ); - }); - - Ok(()) } #[instrument(skip(self, intent_bundles))] pub async fn schedule_intent_bundles( &self, - intent_bundles: Vec, + intent_bundles: Vec, ) -> CommittorServiceResult<()> { - if let Err(err) = self.persister.start_base_intents(&intent_bundles) { - // We will still try to perform the commits, but the fact that we cannot - // persist the intent is very serious and we should probably restart the - // valiator - error!(error = ?err, "DB EXCEPTION: Failed to persist changeset"); - }; - self.commits_scheduler .schedule(intent_bundles) .await @@ -245,7 +138,7 @@ impl CommittorProcessor { pub async fn execute_intent_bundles( &self, - intent_bundles: Vec, + intent_bundles: Vec, ) -> CommittorServiceResult> { // Critical section let (receivers, inserted_ids) = { @@ -299,21 +192,6 @@ impl CommittorProcessor { Ok(results) } - #[instrument(skip(self, intent_bundles))] - pub async fn schedule_recovered_intent_bundles( - &self, - intent_bundles: Vec, - ) -> CommittorServiceResult<()> { - self.commits_scheduler - .schedule(intent_bundles) - .await - .inspect_err(|err| { - error!(error = ?err, "Failed to schedule recovered intent"); - })?; - - Ok(()) - } - /// Creates a subscription for results of BaseIntent execution pub fn subscribe_for_results( &self, diff --git a/magicblock-committor-service/src/consts.rs b/magicblock-committor-service/src/consts.rs deleted file mode 100644 index 3fb495cd3..000000000 --- a/magicblock-committor-service/src/consts.rs +++ /dev/null @@ -1,15 +0,0 @@ -// https://solana.com/docs/core/transactions#transaction-size - -use magicblock_committor_program::{ - consts::MAX_INSTRUCTION_DATA_SIZE, - instruction::IX_WRITE_SIZE_WITHOUT_CHUNKS, -}; - -const BUDGET_SET_COMPUTE_UNIT_PRICE_BYTES: u16 = (1 + 8) * 8; -const BUDGET_SET_COMPUTE_UNIT_LIMIT_BYTES: u16 = (1 + 4) * 8; - -/// The maximum size of a chunk that can be written as part of a single transaction -pub(super) const MAX_WRITE_CHUNK_SIZE: u16 = MAX_INSTRUCTION_DATA_SIZE - - IX_WRITE_SIZE_WITHOUT_CHUNKS - - BUDGET_SET_COMPUTE_UNIT_PRICE_BYTES - - BUDGET_SET_COMPUTE_UNIT_LIMIT_BYTES; diff --git a/magicblock-committor-service/src/error.rs b/magicblock-committor-service/src/error.rs index a44977104..eef33ba9a 100644 --- a/magicblock-committor-service/src/error.rs +++ b/magicblock-committor-service/src/error.rs @@ -1,17 +1,14 @@ use thiserror::Error; use tokio::sync::oneshot::error::RecvError; -use crate::intent_execution_manager::IntentExecutionManagerError; +use crate::intent_engine::intent_channel::IntentScheduleError; pub type CommittorServiceResult = Result; #[derive(Error, Debug)] pub enum CommittorServiceError { - #[error("CommitPersistError: {0} ({0:?})")] - CommitPersistError(#[from] crate::persist::error::CommitPersistError), - - #[error("IntentExecutionManagerError: {0} ({0:?})")] - IntentExecutionManagerError(#[from] IntentExecutionManagerError), + #[error("IntentScheduleError: {0} ({0:?})")] + IntentScheduleError(#[from] IntentScheduleError), #[error("RecvError: {0}")] IntentResultRecvError(#[from] RecvError), diff --git a/magicblock-committor-service/src/intent_engine.rs b/magicblock-committor-service/src/intent_engine.rs new file mode 100644 index 000000000..361f49395 --- /dev/null +++ b/magicblock-committor-service/src/intent_engine.rs @@ -0,0 +1,87 @@ +pub mod db; +pub mod intent_channel; +mod intent_execution_engine; +pub mod intent_scheduler; + +use std::sync::{Arc, Mutex}; + +pub use intent_execution_engine::BroadcastedIntentExecutionResult; +use magicblock_core::traits::ActionsCallbackScheduler; +use magicblock_program::outbox_intent_bundles::OutboxIntentBundle; +use magicblock_rpc_client::MagicblockRpcClient; +use magicblock_table_mania::TableMania; +use tokio::sync::broadcast; + +use crate::{ + intent_engine::{ + db::BacklogDB, + intent_channel::{channel, IntentScheduleError, IntentScheduleHandle}, + intent_execution_engine::{IntentExecutionEngine, ResultSubscriber}, + }, + intent_executor::{ + error::IntentExecutorError, + intent_executor_factory::{ExecutorConfig, IntentExecutorBuilderImpl}, + }, + outbox::OutboxClient, + tasks::task_info_fetcher::{CacheTaskInfoFetcher, RpcTaskInfoFetcher}, +}; + +pub struct IntentEngineHandle { + intent_schedule_handle: IntentScheduleHandle, + result_subscriber: ResultSubscriber, +} + +impl IntentEngineHandle { + pub fn new( + rpc_client: MagicblockRpcClient, + db: D, + task_info_fetcher: Arc>, + outbox_client: Arc, + table_mania: TableMania, + executor_config: ExecutorConfig, + actions_callback_executor: A, + ) -> Self + where + A: ActionsCallbackScheduler, + O: OutboxClient, + O::Error: Into, + { + let db = Arc::new(Mutex::new(db)); + + let executor_factory = IntentExecutorBuilderImpl { + rpc_client, + table_mania, + executor_config, + outbox_client, + task_info_fetcher, + actions_callback_executor, + }; + + let (handle, intent_stream) = channel(&db, 1000); + let worker = + IntentExecutionEngine::new(intent_stream, executor_factory); + let result_subscriber = worker.spawn(); + + Self { + intent_schedule_handle: handle, + result_subscriber, + } + } + + /// Schedules [`ScheduledBaseIntent`] intent to be executed + /// In case the channel is full we write intent to DB + /// Intents will be extracted and handled in the [`IntentExecutionEngine`] + pub async fn schedule( + &self, + intent_bundles: Vec, + ) -> Result<(), IntentScheduleError> { + self.intent_schedule_handle.schedule(intent_bundles) + } + + /// Creates a subscription for results of BaseIntent execution + pub fn subscribe_for_results( + &self, + ) -> broadcast::Receiver { + self.result_subscriber.subscribe() + } +} diff --git a/magicblock-committor-service/src/intent_engine/db.rs b/magicblock-committor-service/src/intent_engine/db.rs new file mode 100644 index 000000000..fde645843 --- /dev/null +++ b/magicblock-committor-service/src/intent_engine/db.rs @@ -0,0 +1,141 @@ +use std::{cell::RefCell, collections::VecDeque, sync::Arc}; + +/// DB for storing intents that overflow committor channel +use magicblock_accounts_db::{traits::AccountsBank, AccountsDb}; +use magicblock_core::intent::outbox::outbox_intent_pda; +use magicblock_metrics::metrics; +use magicblock_program::outbox_intent_bundles::OutboxIntentBundle; +use solana_account::ReadableAccount; + +pub trait BacklogDB: Send + 'static { + fn store_intent_bundle( + &self, + intent_bundle: OutboxIntentBundle, + ) -> DBResult<()>; + fn store_intent_bundles( + &self, + intent_bundles: Vec, + ) -> DBResult<()>; + + /// Returns the oldest (first stored) intent bundle + fn pop_intent_bundle(&self) -> DBResult>; + fn is_empty(&self) -> bool; +} + +pub struct DummyIntentBacklog { + accounts_db: Arc, + queue: RefCell>, +} + +impl DummyIntentBacklog { + pub fn new(accounts_db: Arc) -> Self { + Self { + accounts_db, + queue: RefCell::new(VecDeque::new()), + } + } +} + +impl BacklogDB for DummyIntentBacklog { + fn store_intent_bundle( + &self, + intent_bundle: OutboxIntentBundle, + ) -> DBResult<()> { + let mut queue = self.queue.borrow_mut(); + queue.push_back(intent_bundle.inner.id); + + metrics::set_committor_intents_backlog_count(queue.len() as i64); + Ok(()) + } + + fn store_intent_bundles( + &self, + intent_bundles: Vec, + ) -> DBResult<()> { + let mut queue = self.queue.borrow_mut(); + queue.extend(intent_bundles.into_iter().map(|el| el.inner.id)); + + metrics::set_committor_intents_backlog_count(queue.len() as i64); + Ok(()) + } + + fn pop_intent_bundle(&self) -> DBResult> { + let mut queue = self.queue.borrow_mut(); + let Some(id) = queue.pop_front() else { + return Ok(None); + }; + metrics::set_committor_intents_backlog_count(queue.len() as i64); + drop(queue); + + let intent_pda = outbox_intent_pda(id); + let account = self + .accounts_db + .get_account(&intent_pda) + .ok_or(Error::IntentNotFoundError(id))?; + + let outbox_intent = OutboxIntentBundle::try_from_bytes(account.data())?; + Ok(Some(outbox_intent)) + } + + fn is_empty(&self) -> bool { + self.queue.borrow().is_empty() + } +} + +#[cfg(any(test, feature = "dev-context-only-utils"))] +pub struct DummyDB { + db: std::sync::Mutex>, +} + +#[cfg(any(test, feature = "dev-context-only-utils"))] +impl Default for DummyDB { + fn default() -> Self { + Self { + db: std::sync::Mutex::new(VecDeque::new()), + } + } +} + +#[cfg(any(test, feature = "dev-context-only-utils"))] +impl DummyDB { + pub fn new() -> Self { + Self::default() + } +} + +#[cfg(any(test, feature = "dev-context-only-utils"))] +impl BacklogDB for DummyDB { + fn store_intent_bundle( + &self, + intent_bundle: OutboxIntentBundle, + ) -> DBResult<()> { + self.db.lock().unwrap().push_back(intent_bundle); + Ok(()) + } + + fn store_intent_bundles( + &self, + intent_bundles: Vec, + ) -> DBResult<()> { + self.db.lock().unwrap().extend(intent_bundles); + Ok(()) + } + + fn pop_intent_bundle(&self) -> DBResult> { + Ok(self.db.lock().unwrap().pop_front()) + } + + fn is_empty(&self) -> bool { + self.db.lock().unwrap().is_empty() + } +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Failed to find intent in AccountsDB, id: {0}")] + IntentNotFoundError(u64), + #[error("Failed to deserialize Outbox Account")] + DeserializeError(#[from] bincode::Error), +} + +pub type DBResult = Result; diff --git a/magicblock-committor-service/src/intent_engine/intent_channel.rs b/magicblock-committor-service/src/intent_engine/intent_channel.rs new file mode 100644 index 000000000..48ded0b61 --- /dev/null +++ b/magicblock-committor-service/src/intent_engine/intent_channel.rs @@ -0,0 +1,134 @@ +use std::{ + pin::Pin, + sync::{Arc, Mutex}, + task::{Context, Poll}, +}; + +use futures_util::ready; +use magicblock_program::outbox_intent_bundles::OutboxIntentBundle; +use pin_project::pin_project; +use tokio::sync::{ + mpsc, + mpsc::{error::TrySendError, Receiver, Sender}, +}; +use tokio_stream::{wrappers::ReceiverStream, Stream}; + +use crate::intent_engine::{db, db::BacklogDB}; + +const POISONED_MSG: &str = "Dummy DB mutex poisoned"; + +/// Handle for scheduling intents in ExecutionEngine +pub struct IntentScheduleHandle { + db: Arc>, + sender: Sender, +} + +impl IntentScheduleHandle { + pub fn new(db: Arc>, sender: Sender) -> Self { + Self { db, sender } + } + + pub fn schedule( + &self, + intent_bundles: Vec, + ) -> Result<(), IntentScheduleError> { + // If db not empty push el-t there + // This means that at some point channel got full + // Worker first will clean-up channel, and then DB. + // Pushing into channel would break order of commits + // Lock shall be held across to avoid races + let db = self.db.lock().expect(POISONED_MSG); + if !db.is_empty() { + db.store_intent_bundles(intent_bundles)?; + return Ok(()); + } + + let mut iter = intent_bundles.into_iter(); + // Treated as regular value not propagated lower + #[allow(clippy::result_large_err)] + let res = iter.try_for_each(|el| self.sender.try_send(el)); + match res { + Ok(_) => Ok(()), + Err(TrySendError::Closed(_)) => { + Err(IntentScheduleError::ChannelClosed) + } + Err(TrySendError::Full(el)) => { + let leftovers = std::iter::once(el).chain(iter).collect(); + db.store_intent_bundles(leftovers) + .map_err(IntentScheduleError::from) + } + } + } +} + +/// Stream of Intents that also handles backlog +/// If backlog is not empty we switch to reading from it until it is depleted +/// Once it is depleted we switch to polling `ReceiverStream` +#[pin_project] +pub struct IntentStream { + db: Arc>, + #[pin] + stream: ReceiverStream, +} + +impl IntentStream { + pub fn new( + db: Arc>, + receiver: Receiver, + ) -> Self { + Self { + db, + stream: ReceiverStream::new(receiver), + } + } +} + +impl Stream for IntentStream { + type Item = Result; + + fn poll_next( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + let this = self.project(); + let db = this.db.lock().expect(POISONED_MSG); + // That means we have backlog + // prior to using channel again we have to clean it all first + if !db.is_empty() { + // Before starting to clean backlog we need to clean channel first. + // A closed channel (`Ready(None)`) must NOT end the stream here - + // backlog still has items to drain, and the channel closing + // doesn't mean there's no more work left. + if let Poll::Ready(Some(item)) = this.stream.poll_next(cx) { + Poll::Ready(Some(Ok(item))) + } else { + // Some(T) always will be returned here as per check above + let el = db.pop_intent_bundle(); + Poll::Ready(el.transpose()) + } + } else { + let item = ready!(this.stream.poll_next(cx)); + Poll::Ready(item.map(Ok)) + } + } +} + +pub(crate) fn channel( + db: &Arc>, + buffer: usize, +) -> (IntentScheduleHandle, IntentStream) { + let (sender, receiver) = mpsc::channel(buffer); + + let handle = IntentScheduleHandle::new(db.clone(), sender); + let stream = IntentStream::new(db.clone(), receiver); + + (handle, stream) +} + +#[derive(thiserror::Error, Debug)] +pub enum IntentScheduleError { + #[error("Channel was closed")] + ChannelClosed, + #[error("DBError: {0}")] + DBError(#[from] db::Error), +} diff --git a/magicblock-committor-service/src/intent_execution_manager/intent_execution_engine.rs b/magicblock-committor-service/src/intent_engine/intent_execution_engine.rs similarity index 78% rename from magicblock-committor-service/src/intent_execution_manager/intent_execution_engine.rs rename to magicblock-committor-service/src/intent_engine/intent_execution_engine.rs index 593504f7e..d4a74a601 100644 --- a/magicblock-committor-service/src/intent_execution_manager/intent_execution_engine.rs +++ b/magicblock-committor-service/src/intent_engine/intent_execution_engine.rs @@ -1,38 +1,37 @@ use std::{ + marker::PhantomData, ops::Deref, sync::{Arc, Mutex}, time::{Duration, Instant}, }; -use futures_util::{stream::FuturesUnordered, StreamExt}; +use futures_util::stream::FuturesUnordered; use magicblock_core::traits::CallbackScheduleError; use magicblock_metrics::metrics; -use magicblock_program::magic_scheduled_base_intent::ScheduledIntentBundle; +use magicblock_program::outbox_intent_bundles::OutboxIntentBundle; use solana_signature::Signature; use tokio::{ - sync::{ - broadcast, mpsc, mpsc::error::TryRecvError, OwnedSemaphorePermit, - Semaphore, - }, + sync::{broadcast, OwnedSemaphorePermit, Semaphore}, task::JoinHandle, }; +use tokio_stream::StreamExt; use tracing::{error, info, instrument, trace, warn}; +#[cfg(feature = "dev-context-only-utils")] +use crate::tasks::task_strategist::TransactionStrategy; use crate::{ - intent_execution_manager::{ - db::DB, + intent_engine::{ + db::BacklogDB, + intent_channel::{IntentScheduleError, IntentStream}, intent_scheduler::{IntentScheduler, POISONED_INNER_MSG}, - IntentExecutionManagerError, }, intent_executor::{ - error::{ - IntentExecutorError, IntentExecutorResult, - TransactionStrategyExecutionError, - }, - intent_executor_factory::IntentExecutorFactory, + error::{IntentExecutorError, IntentExecutorResult}, + intent_executor_factory::IntentExecutorBuilder, + strategy_executor::error::TransactionStrategyExecutionError, ExecutionOutput, IntentExecutionResult, IntentExecutor, }, - persist::IntentPersister, + transaction_preparator::TransactionPreparator, }; const SEMAPHORE_CLOSED_MSG: &str = "Executors semaphore closed!"; @@ -47,6 +46,8 @@ pub struct BroadcastedIntentExecutionResult { pub id: u64, pub patched_errors: Arc, pub callbacks_report: Vec>>, + #[cfg(feature = "dev-context-only-utils")] + pub successful_transaction_strategies: Vec, } impl BroadcastedIntentExecutionResult { @@ -64,6 +65,9 @@ impl BroadcastedIntentExecutionResult { patched_errors, inner, callbacks_report, + #[cfg(feature = "dev-context-only-utils")] + successful_transaction_strategies: execution_result + .successful_transaction_strategies, } } } @@ -88,40 +92,32 @@ impl ResultSubscriber { } } -pub(crate) struct IntentExecutionEngine { - db: Arc, - executor_factory: F, - intents_persister: Option

, - receiver: mpsc::Receiver, +pub(crate) struct IntentExecutionEngine { + intent_stream: IntentStream, + executor_builder: F, inner: Arc>, running_executors: FuturesUnordered>, executors_semaphore: Arc, + _phantom_data: PhantomData, } -impl IntentExecutionEngine +impl IntentExecutionEngine where - D: DB, - P: IntentPersister, - F: IntentExecutorFactory + Send + Sync + 'static, - E: IntentExecutor, + D: BacklogDB, + T: TransactionPreparator, + F: IntentExecutorBuilder + Send + Sync + 'static, { - pub fn new( - db: Arc, - executor_factory: F, - intents_persister: Option

, - receiver: mpsc::Receiver, - ) -> Self { + pub fn new(intent_stream: IntentStream, executor_factory: F) -> Self { Self { - db, - intents_persister, - executor_factory, - receiver, + intent_stream, + executor_builder: executor_factory, running_executors: FuturesUnordered::new(), executors_semaphore: Arc::new(Semaphore::new( MAX_EXECUTORS as usize, )), inner: Arc::new(Mutex::new(IntentScheduler::new())), + _phantom_data: PhantomData, } } @@ -145,13 +141,14 @@ where loop { let intent = match self.next_scheduled_intent().await { Ok(value) => value, - Err(IntentExecutionManagerError::ChannelClosed) => { + Err(IntentScheduleError::ChannelClosed) => { info!("Channel closed, exiting"); break; } - Err(IntentExecutionManagerError::DBError(err)) => { + Err(IntentScheduleError::DBError(err)) => { + // TODO(edwin): add to alert as this is critical error error!(error = ?err, "Failed to fetch intent"); - break; + continue; } }; let Some(intent) = intent else { @@ -171,13 +168,13 @@ where .expect(SEMAPHORE_CLOSED_MSG); // Spawn executor - let executor = self.executor_factory.create_instance(); - let persister = self.intents_persister.clone(); + let executor = self + .executor_builder + .create_instance(intent.status().clone()); let inner = self.inner.clone(); let handle = tokio::spawn(Self::execute( executor, - persister, intent, inner, permit, @@ -191,12 +188,11 @@ where } } - /// Returns [`ScheduledIntentBundle`] or None if all intents are blocked + /// Returns [`OutboxIntentBundle`] or None if all intents are blocked #[instrument(skip(self))] async fn next_scheduled_intent( &mut self, - ) -> Result, IntentExecutionManagerError> - { + ) -> Result, IntentScheduleError> { // Limit on number of intents that can be stored in scheduler const SCHEDULER_CAPACITY: usize = 1000; @@ -215,8 +211,7 @@ where }; let running_executors = &mut self.running_executors; - let receiver = &mut self.receiver; - let db = &self.db; + let intent_stream = &mut self.intent_stream; let intent = tokio::select! { // Notify polled first to prioritize unblocked intents over new one biased; @@ -227,7 +222,7 @@ where trace!("Worker executed intent bundle, fetching new available one"); self.inner.lock().expect(POISONED_INNER_MSG).pop_next_scheduled_intent() }, - result = Self::get_new_intent(receiver, db), if can_receive() => { + result = Self::get_new_intent(intent_stream), if can_receive() => { let intent = result?; self.inner.lock().expect(POISONED_INNER_MSG).schedule(intent) }, @@ -246,35 +241,20 @@ where /// Returns [`ScheduledIntentBundle`] from external channel async fn get_new_intent( - receiver: &mut mpsc::Receiver, - db: &Arc, - ) -> Result { - match receiver.try_recv() { - Ok(val) => Ok(val), - Err(TryRecvError::Empty) => { - // Worker either cleaned-up congested channel and now need to clean-up DB - // or we're just waiting on empty channel - if let Some(intent_bundle) = db.pop_intent_bundle().await? { - Ok(intent_bundle) - } else { - receiver - .recv() - .await - .ok_or(IntentExecutionManagerError::ChannelClosed) - } - } - Err(TryRecvError::Disconnected) => { - Err(IntentExecutionManagerError::ChannelClosed) - } - } + intent_stream: &mut IntentStream, + ) -> Result { + intent_stream + .next() + .await + .ok_or(IntentScheduleError::ChannelClosed)? + .map_err(IntentScheduleError::DBError) } /// Wrapper on [`IntentExecutor`] that handles its results and drops execution permit - #[instrument(skip(executor, persister, intent, inner_scheduler, execution_permit, result_sender), fields(intent_id = intent.id))] + #[instrument(skip(executor, intent, inner_scheduler, execution_permit, result_sender), fields(intent_id = intent.id))] async fn execute( - mut executor: E, - persister: Option

, - intent: ScheduledIntentBundle, + executor: Box>, + intent: OutboxIntentBundle, inner_scheduler: Arc>, execution_permit: OwnedSemaphorePermit, result_sender: broadcast::Sender, @@ -282,7 +262,8 @@ where let instant = Instant::now(); // Execute an Intent - let result = executor.execute(intent.clone(), persister).await; + let (result, cleanup_handle) = + executor.execute(intent.inner.clone()).await; let _ = result.inner.as_ref().inspect_err(|err| { error!(intent_id = intent.id, error = ?err, "Failed to execute intent bundle"); }); @@ -297,6 +278,8 @@ where warn!(error = ?err, "No result listeners"); } + // TODO(edwin): if intent is Err(T) we need to "lock" pubkeys somehow + // That would // Remove executed task from Scheduler to unblock other intents // SAFETY: Self::execute is called ONLY after IntentScheduler // successfully is able to schedule execution of some Intent @@ -308,7 +291,7 @@ where .expect("Valid completion of previously scheduled message"); tokio::spawn(async move { - if let Err(err) = executor.cleanup().await { + if let Err(err) = cleanup_handle.clean().await { error!(error = ?err, "Failed to cleanup after intent"); } }); @@ -320,7 +303,7 @@ where /// Records metrics related to intent execution fn execution_metrics( execution_time: Duration, - intent: &ScheduledIntentBundle, + intent: &OutboxIntentBundle, result: &IntentExecutorResult, ) { const EXECUTION_TIME_THRESHOLD: f64 = 5.0; @@ -366,62 +349,91 @@ mod tests { }; use async_trait::async_trait; - use magicblock_program::magic_scheduled_base_intent::ScheduledIntentBundle; + use solana_keypair::Keypair; + use solana_message::VersionedMessage; use solana_pubkey::{pubkey, Pubkey}; use solana_signature::Signature; use solana_signer::SignerError; use solana_transaction_error::TransactionError; - use tokio::{sync::mpsc, time::sleep}; + use tokio::time::sleep; use super::*; use crate::{ - intent_execution_manager::{ - db::{DummyDB, DB}, + intent_engine::{ + db::{BacklogDB, DummyDB}, + intent_channel::{channel, IntentScheduleHandle}, intent_scheduler::{create_test_intent, create_test_intent_bundle}, }, intent_executor::{ + cleanup_handle::CleanupHandle, error::{IntentExecutorError as ExecutorError, InternalError}, + intent_executor_factory::IntentExecutorBuilder, IntentExecutionResult, }, - persist::IntentPersisterImpl, + tasks::task_strategist::TransactionStrategy, test_utils, - transaction_preparator::delivery_preparator::BufferExecutionError, + transaction_preparator::{ + delivery_preparator::{ + BufferExecutionError, DeliveryPreparatorResult, + }, + error::PreparatorResult, + TransactionPreparator, + }, }; + struct MockTransactionPreparator; + + #[async_trait] + impl TransactionPreparator for MockTransactionPreparator { + async fn prepare_for_strategy( + &self, + _authority: &Keypair, + _transaction_strategy: &mut TransactionStrategy, + ) -> PreparatorResult { + unimplemented!() + } + + async fn cleanup_for_strategy( + &self, + _authority: &Keypair, + _tasks: &[crate::tasks::BaseTaskImpl], + _lookup_table_keys: &[Pubkey], + _close_buffers: bool, + ) -> DeliveryPreparatorResult<(), BufferExecutionError> { + Ok(()) + } + } + type MockIntentExecutionEngine = IntentExecutionEngine< DummyDB, - IntentPersisterImpl, MockIntentExecutorFactory, + MockTransactionPreparator, >; fn setup_engine( should_fail: bool, ) -> ( - mpsc::Sender, + IntentScheduleHandle, MockIntentExecutionEngine, + Arc>, ) { test_utils::init_test_logger(); - let (sender, receiver) = mpsc::channel(1000); - - let db = Arc::new(DummyDB::new()); + let db = Arc::new(Mutex::new(DummyDB::new())); + let (handle, intent_stream) = channel(&db, 1000); let executor_factory = if !should_fail { MockIntentExecutorFactory::new() } else { MockIntentExecutorFactory::new_failing() }; - let worker = IntentExecutionEngine::new( - db.clone(), - executor_factory, - None::, - receiver, - ); + let worker = + IntentExecutionEngine::new(intent_stream, executor_factory); - (sender, worker) + (handle, worker, db) } #[tokio::test] async fn test_worker_processes_messages() { - let (sender, worker) = setup_engine(false); + let (sender, worker, _db) = setup_engine(false); let result_subscriber = worker.spawn(); let mut result_receiver = result_subscriber.subscribe(); @@ -431,7 +443,7 @@ mod tests { &[pubkey!("1111111111111111111111111111111111111111111")], false, ); - sender.send(msg.clone()).await.unwrap(); + sender.schedule(vec![msg.clone()]).unwrap(); // Verify the message was processed let result = result_receiver.recv().await.unwrap(); @@ -441,7 +453,7 @@ mod tests { #[tokio::test] async fn test_worker_handles_conflicting_messages() { - let (sender, worker) = setup_engine(false); + let (sender, worker, _db) = setup_engine(false); let result_subscriber = worker.spawn(); let mut result_receiver = result_subscriber.subscribe(); @@ -450,8 +462,8 @@ mod tests { let msg1 = create_test_intent(1, &[pubkey], false); let msg2 = create_test_intent(2, &[pubkey], false); - sender.send(msg1.clone()).await.unwrap(); - sender.send(msg2.clone()).await.unwrap(); + sender.schedule(vec![msg1.clone()]).unwrap(); + sender.schedule(vec![msg2.clone()]).unwrap(); // First message should be processed immediately let result1 = result_receiver.recv().await.unwrap(); @@ -466,7 +478,7 @@ mod tests { #[tokio::test] async fn test_worker_handles_conflicting_bundles() { - let (sender, worker) = setup_engine(false); + let (sender, worker, _db) = setup_engine(false); let result_subscriber = worker.spawn(); let mut result_receiver = result_subscriber.subscribe(); @@ -476,8 +488,8 @@ mod tests { let msg1 = create_test_intent_bundle(1, &[a], &[b]); let msg2 = create_test_intent(2, &[a], false); - sender.send(msg1.clone()).await.unwrap(); - sender.send(msg2.clone()).await.unwrap(); + sender.schedule(vec![msg1.clone()]).unwrap(); + sender.schedule(vec![msg2.clone()]).unwrap(); // First message should be processed immediately let result1 = result_receiver.recv().await.unwrap(); @@ -492,7 +504,7 @@ mod tests { #[tokio::test] async fn test_worker_handles_executor_failure() { - let (sender, worker) = setup_engine(true); + let (sender, worker, _db) = setup_engine(true); let result_subscriber = worker.spawn(); let mut result_receiver = result_subscriber.subscribe(); @@ -502,7 +514,7 @@ mod tests { &[pubkey!("1111111111111111111111111111111111111111111")], false, ); - sender.send(msg.clone()).await.unwrap(); + sender.schedule(vec![msg.clone()]).unwrap(); // Verify the failure was properly reported let result = result_receiver.recv().await.unwrap(); @@ -520,7 +532,7 @@ mod tests { #[tokio::test] async fn test_worker_falls_back_to_db_when_channel_empty() { - let (_sender, worker) = setup_engine(false); + let (_sender, worker, db) = setup_engine(false); // Add a message to the DB let msg = create_test_intent( @@ -528,7 +540,7 @@ mod tests { &[pubkey!("1111111111111111111111111111111111111111111")], false, ); - worker.db.store_intent_bundle(msg.clone()).await.unwrap(); + db.lock().unwrap().store_intent_bundle(msg.clone()).unwrap(); // Start worker let result_subscriber = worker.spawn(); @@ -545,12 +557,12 @@ mod tests { async fn test_high_throughput_message_processing() { const NUM_MESSAGES: usize = 20; - let (sender, mut worker) = setup_engine(false); + let (sender, mut worker, _db) = setup_engine(false); let active_tasks = Arc::new(AtomicUsize::new(0)); let max_concurrent = Arc::new(AtomicUsize::new(0)); worker - .executor_factory + .executor_builder .with_concurrency_tracking(&active_tasks, &max_concurrent); let result_subscriber = worker.spawn(); @@ -563,7 +575,7 @@ mod tests { &[pubkey!("1111111111111111111111111111111111111111111")], false, ); - sender.send(msg).await.unwrap(); + sender.schedule(vec![msg]).unwrap(); } // Process results and verify constraints @@ -587,7 +599,7 @@ mod tests { /// Tests that errors from executor propagated gracefully #[tokio::test] async fn test_multiple_failures() { - let (sender, worker) = setup_engine(true); // Worker that always fails + let (sender, worker, _db) = setup_engine(true); // Worker that always fails let result_subscriber = worker.spawn(); let mut result_receiver = result_subscriber.subscribe(); @@ -599,7 +611,7 @@ mod tests { &[pubkey!("1111111111111111111111111111111111111111111")], false, ); - sender.send(msg).await.unwrap(); + sender.schedule(vec![msg]).unwrap(); } // Verify all failures are processed and semaphore slots released @@ -613,12 +625,12 @@ mod tests { async fn test_non_blocking_messages() { const NUM_MESSAGES: u64 = 200; - let (sender, mut worker) = setup_engine(false); + let (sender, mut worker, _db) = setup_engine(false); let active_tasks = Arc::new(AtomicUsize::new(0)); let max_concurrent = Arc::new(AtomicUsize::new(0)); worker - .executor_factory + .executor_builder .with_concurrency_tracking(&active_tasks, &max_concurrent); let result_subscriber = worker.spawn(); @@ -631,7 +643,7 @@ mod tests { let msg = create_test_intent(i, &[unique_pubkey], false); received_ids.insert(i); - sender.send(msg).await.unwrap(); + sender.schedule(vec![msg]).unwrap(); } // Process results @@ -671,12 +683,12 @@ mod tests { // 30% blocking messages const BLOCKING_RATIO: f32 = 0.3; - let (sender, mut worker) = setup_engine(false); + let (sender, mut worker, _db) = setup_engine(false); let active_tasks = Arc::new(AtomicUsize::new(0)); let max_concurrent = Arc::new(AtomicUsize::new(0)); worker - .executor_factory + .executor_builder .with_concurrency_tracking(&active_tasks, &max_concurrent); let result_subscriber = worker.spawn(); @@ -695,7 +707,7 @@ mod tests { }; let msg = create_test_intent(i as u64, &pubkeys, false); - sender.send(msg).await.unwrap(); + sender.schedule(vec![msg]).unwrap(); } // Process results @@ -749,15 +761,18 @@ mod tests { } } - impl IntentExecutorFactory for MockIntentExecutorFactory { - type Executor = MockIntentExecutor; - - fn create_instance(&self) -> Self::Executor { - MockIntentExecutor { + impl IntentExecutorBuilder + for MockIntentExecutorFactory + { + fn create_instance( + &self, + _status: magicblock_program::outbox_intent_bundles::OutboxIntentBundleStatus, + ) -> Box> { + Box::new(MockIntentExecutor { should_fail: self.should_fail, active_tasks: self.active_tasks.clone(), max_concurrent: self.max_concurrent.clone(), - } + }) } } @@ -799,12 +814,14 @@ mod tests { } #[async_trait] - impl IntentExecutor for MockIntentExecutor { - async fn execute( - &mut self, - _base_intent: ScheduledIntentBundle, - _persister: Option

, - ) -> IntentExecutionResult { + impl IntentExecutor for MockIntentExecutor { + async fn execute( + self: Box, + _base_intent: magicblock_program::magic_scheduled_base_intent::ScheduledIntentBundle, + ) -> ( + IntentExecutionResult, + CleanupHandle, + ) { self.on_task_started(); // Simulate some work @@ -841,11 +858,13 @@ mod tests { self.on_task_finished(); - result - } - - async fn cleanup(self) -> Result<(), BufferExecutionError> { - Ok(()) + let cleanup = CleanupHandle::new( + Keypair::new(), + vec![], + false, + MockTransactionPreparator, + ); + (result, cleanup) } } } diff --git a/magicblock-committor-service/src/intent_execution_manager/intent_scheduler.rs b/magicblock-committor-service/src/intent_engine/intent_scheduler.rs similarity index 98% rename from magicblock-committor-service/src/intent_execution_manager/intent_scheduler.rs rename to magicblock-committor-service/src/intent_engine/intent_scheduler.rs index 580acebf6..b8ef05761 100644 --- a/magicblock-committor-service/src/intent_execution_manager/intent_scheduler.rs +++ b/magicblock-committor-service/src/intent_engine/intent_scheduler.rs @@ -1,6 +1,6 @@ use std::collections::{hash_map::Entry, HashMap, VecDeque}; -use magicblock_program::magic_scheduled_base_intent::ScheduledIntentBundle; +use magicblock_program::outbox_intent_bundles::OutboxIntentBundle; use solana_pubkey::Pubkey; use thiserror::Error; use tracing::error; @@ -11,7 +11,7 @@ pub(crate) const POISONED_INNER_MSG: &str = type IntentID = u64; struct IntentMeta { num_keys: usize, - intent: ScheduledIntentBundle, + intent: OutboxIntentBundle, } /// A scheduler that ensures mutually exclusive access to pubkeys across intents @@ -78,8 +78,8 @@ impl IntentScheduler { /// otherwise consumes it and enqueues pub fn schedule( &mut self, - intent_bundle: ScheduledIntentBundle, - ) -> Option { + intent_bundle: OutboxIntentBundle, + ) -> Option { let intent_id = intent_bundle.id; // To check duplicate scheduling its enough to check: @@ -148,7 +148,7 @@ impl IntentScheduler { /// NOTE: this shall be called on executing intents to finilize their execution. pub fn complete( &mut self, - intent_bundle: &ScheduledIntentBundle, + intent_bundle: &OutboxIntentBundle, ) -> IntentSchedulerResult<()> { // Release data for completed intent let intent_id = intent_bundle.id; @@ -233,9 +233,7 @@ impl IntentScheduler { } // Returns [`ScheduledBaseIntent`] that can be executed - pub fn pop_next_scheduled_intent( - &mut self, - ) -> Option { + pub fn pop_next_scheduled_intent(&mut self) -> Option { // TODO(edwin): optimize. Create counter im IntentMeta & update let mut execute_candidates: HashMap = HashMap::new(); self.blocked_keys.iter().for_each(|(_, queue)| { @@ -627,7 +625,7 @@ mod edge_cases_test { setup(); let mut scheduler = IntentScheduler::new(); let mut msg = create_test_intent(1, &[], false); - msg.intent_bundle = MagicIntentBundle::default(); + msg.inner.intent_bundle = MagicIntentBundle::default(); // Should execute immediately since it has no pubkeys assert!(scheduler.schedule(msg.clone()).is_some()); @@ -681,7 +679,7 @@ mod complete_error_test { let msg2 = create_test_intent(2, &[pubkey1], false); assert!(scheduler.schedule(msg2.clone()).is_none()); - msg1.get_commit_intent_accounts_mut().unwrap().pop(); + msg1.inner.get_commit_intent_accounts_mut().unwrap().pop(); // Attempt to complete msg1 - should detect corrupted state let result = scheduler.complete(&msg1); @@ -703,7 +701,8 @@ mod complete_error_test { let mut msg1 = create_test_intent(1, &[pubkey1, pubkey2], false); assert!(scheduler.schedule(msg1.clone()).is_some()); - msg1.intent_bundle + msg1.inner + .intent_bundle .get_commit_intent_accounts_mut() .unwrap() .push(CommittedAccount { @@ -853,7 +852,7 @@ pub(crate) fn create_test_intent( id: u64, pubkeys: &[Pubkey], is_undelegate: bool, -) -> ScheduledIntentBundle { +) -> OutboxIntentBundle { use magicblock_core::intent::CommittedAccount; use magicblock_program::magic_scheduled_base_intent::{ CommitAndUndelegate, CommitType, MagicIntentBundle, @@ -872,7 +871,6 @@ pub(crate) fn create_test_intent( intent_bundle: MagicIntentBundle::default(), }; - // Only set pubkeys if provided if !pubkeys.is_empty() { let committed_accounts = pubkeys .iter() @@ -895,7 +893,7 @@ pub(crate) fn create_test_intent( } } - intent + OutboxIntentBundle::accepted(intent) } #[cfg(test)] @@ -903,7 +901,7 @@ pub(crate) fn create_test_intent_bundle( id: u64, commit_pubkeys: &[Pubkey], commit_and_undelegate_pubkeys: &[Pubkey], -) -> ScheduledIntentBundle { +) -> OutboxIntentBundle { use magicblock_core::intent::CommittedAccount; use magicblock_program::magic_scheduled_base_intent::{ CommitAndUndelegate, CommitType, MagicIntentBundle, @@ -948,5 +946,5 @@ pub(crate) fn create_test_intent_bundle( }); } - intent + OutboxIntentBundle::accepted(intent) } diff --git a/magicblock-committor-service/src/intent_execution_manager.rs b/magicblock-committor-service/src/intent_execution_manager.rs deleted file mode 100644 index 725224386..000000000 --- a/magicblock-committor-service/src/intent_execution_manager.rs +++ /dev/null @@ -1,121 +0,0 @@ -pub(crate) mod db; -mod intent_execution_engine; -pub mod intent_scheduler; - -use std::sync::Arc; - -pub use intent_execution_engine::BroadcastedIntentExecutionResult; -use magicblock_core::traits::ActionsCallbackScheduler; -use magicblock_program::magic_scheduled_base_intent::ScheduledIntentBundle; -use magicblock_rpc_client::MagicblockRpcClient; -use magicblock_table_mania::TableMania; -use tokio::sync::{broadcast, mpsc, mpsc::error::TrySendError}; - -use crate::{ - intent_execution_manager::{ - db::DB, - intent_execution_engine::{IntentExecutionEngine, ResultSubscriber}, - }, - intent_executor::{ - intent_executor_factory::{ExecutorConfig, IntentExecutorFactoryImpl}, - task_info_fetcher::{CacheTaskInfoFetcher, RpcTaskInfoFetcher}, - }, - persist::IntentPersister, -}; - -pub struct IntentExecutionManager { - db: Arc, - result_subscriber: ResultSubscriber, - intent_sender: mpsc::Sender, -} - -impl IntentExecutionManager { - pub fn new( - rpc_client: MagicblockRpcClient, - db: D, - task_info_fetcher: Arc>, - intent_persister: Option

, - table_mania: TableMania, - executor_config: ExecutorConfig, - actions_callback_executor: A, - ) -> Self - where - A: ActionsCallbackScheduler, - P: IntentPersister, - { - let db = Arc::new(db); - - let executor_factory = IntentExecutorFactoryImpl { - rpc_client, - table_mania, - executor_config, - task_info_fetcher, - actions_callback_executor, - }; - - let (sender, receiver) = mpsc::channel(1000); - let worker = IntentExecutionEngine::new( - db.clone(), - executor_factory, - intent_persister, - receiver, - ); - let result_subscriber = worker.spawn(); - - Self { - db, - intent_sender: sender, - result_subscriber, - } - } - - /// Schedules [`ScheduledBaseIntent`] intent to be executed - /// In case the channel is full we write intent to DB - /// Intents will be extracted and handled in the [`IntentExecutionEngine`] - pub async fn schedule( - &self, - intent_bundles: Vec, - ) -> Result<(), IntentExecutionManagerError> { - // If db not empty push el-t there - // This means that at some point channel got full - // Worker first will clean-up channel, and then DB. - // Pushing into channel would break order of commits - if !self.db.is_empty() { - self.db.store_intent_bundles(intent_bundles).await?; - return Ok(()); - } - - let mut iter = intent_bundles.into_iter(); - // Treated as regular value not propagated lower - #[allow(clippy::result_large_err)] - let res = iter.try_for_each(|el| self.intent_sender.try_send(el)); - match res { - Ok(_) => Ok(()), - Err(TrySendError::Closed(_)) => { - Err(IntentExecutionManagerError::ChannelClosed) - } - Err(TrySendError::Full(el)) => { - let leftovers = std::iter::once(el).chain(iter).collect(); - self.db - .store_intent_bundles(leftovers) - .await - .map_err(IntentExecutionManagerError::from) - } - } - } - - /// Creates a subscription for results of BaseIntent execution - pub fn subscribe_for_results( - &self, - ) -> broadcast::Receiver { - self.result_subscriber.subscribe() - } -} - -#[derive(thiserror::Error, Debug)] -pub enum IntentExecutionManagerError { - #[error("Channel was closed")] - ChannelClosed, - #[error("DBError: {0}")] - DBError(#[from] db::Error), -} diff --git a/magicblock-committor-service/src/intent_execution_manager/db.rs b/magicblock-committor-service/src/intent_execution_manager/db.rs deleted file mode 100644 index 8c58e2b3e..000000000 --- a/magicblock-committor-service/src/intent_execution_manager/db.rs +++ /dev/null @@ -1,87 +0,0 @@ -use std::{collections::VecDeque, sync::Mutex}; - -/// DB for storing intents that overflow committor channel -use async_trait::async_trait; -use magicblock_metrics::metrics; -use magicblock_program::magic_scheduled_base_intent::ScheduledIntentBundle; - -const POISONED_MUTEX_MSG: &str = "Dummy db mutex poisoned"; - -#[async_trait] -pub trait DB: Send + Sync + 'static { - async fn store_intent_bundle( - &self, - intent_bundle: ScheduledIntentBundle, - ) -> DBResult<()>; - async fn store_intent_bundles( - &self, - intent_bundles: Vec, - ) -> DBResult<()>; - - /// Returns the oldest (first stored) intent bundle - async fn pop_intent_bundle( - &self, - ) -> DBResult>; - fn is_empty(&self) -> bool; -} - -pub(crate) struct DummyDB { - db: Mutex>, -} - -impl DummyDB { - pub fn new() -> Self { - Self { - db: Mutex::new(VecDeque::new()), - } - } -} - -#[async_trait] -impl DB for DummyDB { - async fn store_intent_bundle( - &self, - intent_bundle: ScheduledIntentBundle, - ) -> DBResult<()> { - let mut db = self.db.lock().expect(POISONED_MUTEX_MSG); - db.push_back(intent_bundle); - - metrics::set_committor_intents_backlog_count(db.len() as i64); - Ok(()) - } - - async fn store_intent_bundles( - &self, - intent_bundles: Vec, - ) -> DBResult<()> { - let mut db = self.db.lock().expect(POISONED_MUTEX_MSG); - db.extend(intent_bundles); - - metrics::set_committor_intents_backlog_count(db.len() as i64); - Ok(()) - } - - async fn pop_intent_bundle( - &self, - ) -> DBResult> { - let mut db = self.db.lock().expect(POISONED_MUTEX_MSG); - let res = db.pop_front(); - - metrics::set_committor_intents_backlog_count(db.len() as i64); - Ok(res) - } - - fn is_empty(&self) -> bool { - self.db.lock().expect(POISONED_MUTEX_MSG).is_empty() - } -} - -#[derive(thiserror::Error, Debug)] -pub enum Error { - #[error("StoreError")] - StoreError, - #[error("FetchError")] - FetchError, -} - -pub type DBResult = Result; diff --git a/magicblock-committor-service/src/intent_executor/accepted_intent_executor.rs b/magicblock-committor-service/src/intent_executor/accepted_intent_executor.rs new file mode 100644 index 000000000..6f41c7555 --- /dev/null +++ b/magicblock-committor-service/src/intent_executor/accepted_intent_executor.rs @@ -0,0 +1,249 @@ +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use magicblock_core::traits::ActionsCallbackScheduler; +use magicblock_program::{ + magic_scheduled_base_intent::ScheduledIntentBundle, + validator::validator_authority, +}; +use solana_keypair::Keypair; +use solana_signer::Signer; +use tracing::trace; + +use crate::{ + intent_executor::{ + cleanup_handle::CleanupHandle, + error::{IntentExecutorError, IntentExecutorResult}, + strategy_executor::two_stage::Initialized, + utils::{ + build_commit_finalize_tasks, execute_single_stage_flow, + execute_two_stage_flow, + }, + ExecutionOutput, IntentExecutionReport, IntentExecutionResult, + IntentExecutor, IntentExecutorCtx, + }, + outbox::OutboxClient, + tasks::{ + task_builder::TasksBuilder, + task_info_fetcher::{ResetType, TaskInfoFetcher}, + task_strategist::{ + StrategyExecutionMode, TaskStrategist, TransactionStrategy, + TwoStageExecutionMode, + }, + TaskBuilderImpl, + }, + transaction_preparator::TransactionPreparator, +}; + +pub struct AcceptedIntentExecutor { + ctx: IntentExecutorCtx, + authority: Keypair, + /// Timeout for Intent's actions + pub actions_timeout: Duration, + /// Intent execution started at + pub started_at: Instant, +} + +impl AcceptedIntentExecutor +where + T: TransactionPreparator, + F: TaskInfoFetcher, + A: ActionsCallbackScheduler, + O: OutboxClient, + O::Error: Into, +{ + pub fn new( + ctx: IntentExecutorCtx, + actions_timeout: Duration, + ) -> Self { + let authority = validator_authority(); + Self { + ctx, + authority, + actions_timeout, + started_at: Instant::now(), + } + } + + async fn execute_inner( + &mut self, + intent_bundle: ScheduledIntentBundle, + execution_report: &mut IntentExecutionReport, + ) -> IntentExecutorResult { + if intent_bundle.is_empty() { + return Err(IntentExecutorError::EmptyIntentError); + } + let all_committed_pubkeys = intent_bundle.get_all_committed_pubkeys(); + + if all_committed_pubkeys.is_empty() { + // Build tasks for commit stage + // TODO (snawaz): it's actually MagicBaseIntent::BaseActions scenario, not Commit + // scenario, so the related code needs little bit of refactoring and proper renaming. + let commit_tasks = TaskBuilderImpl::commit_tasks( + &self.ctx.task_info_fetcher, + &intent_bundle, + ) + .await?; + + // Standalone actions executed in single stage + let mut strategy = TaskStrategist::build_strategy( + commit_tasks, + &self.authority.pubkey(), + )?; + strategy.standalone_action_nonce = Some(intent_bundle.id); + return self + .single_stage_execution_flow( + intent_bundle, + strategy, + execution_report, + ) + .await; + }; + + // Build tasks for commit & finalize stages + let (commit_tasks, finalize_tasks) = build_commit_finalize_tasks( + &intent_bundle, + &self.ctx.task_info_fetcher, + ) + .await?; + + // Build execution strategy + match TaskStrategist::build_execution_strategy( + commit_tasks, + finalize_tasks, + &self.authority.pubkey(), + )? { + StrategyExecutionMode::SingleStage(strategy) => { + trace!("Single stage execution"); + self.single_stage_execution_flow( + intent_bundle, + strategy, + execution_report, + ) + .await + } + StrategyExecutionMode::TwoStage(TwoStageExecutionMode { + commit_stage, + finalize_stage, + }) => { + trace!("Two stage execution"); + self.two_stage_execution_flow( + intent_bundle, + commit_stage, + finalize_stage, + execution_report, + ) + .await + } + } + } + + fn time_left(&self) -> Option { + self.actions_timeout.checked_sub(self.started_at.elapsed()) + } + + /// Starting execution from single stage + pub async fn single_stage_execution_flow( + &mut self, + intent_bundle: ScheduledIntentBundle, + transaction_strategy: TransactionStrategy, + execution_report: &mut IntentExecutionReport, + ) -> IntentExecutorResult { + execute_single_stage_flow( + &self.ctx, + &self.authority, + intent_bundle, + transaction_strategy, + execution_report, + || self.time_left(), + ) + .await + } + + pub async fn two_stage_execution_flow( + &mut self, + intent_bundle: ScheduledIntentBundle, + commit_strategy: TransactionStrategy, + finalize_strategy: TransactionStrategy, + execution_report: &mut IntentExecutionReport, + ) -> IntentExecutorResult { + let state = Initialized::new(commit_strategy, finalize_strategy); + execute_two_stage_flow( + &self.ctx, + state, + &self.authority, + intent_bundle, + execution_report, + || self.time_left(), + ) + .await + } +} + +#[async_trait] +impl IntentExecutor for AcceptedIntentExecutor +where + T: TransactionPreparator, + C: TaskInfoFetcher, + A: ActionsCallbackScheduler, + O: OutboxClient, + O::Error: Into, +{ + /// Executes Message on Base layer + /// Returns `ExecutionOutput` or an `Error` + async fn execute( + mut self: Box, + base_intent: ScheduledIntentBundle, + ) -> (IntentExecutionResult, CleanupHandle) { + self.started_at = Instant::now(); + let pubkeys = base_intent.get_all_committed_pubkeys(); + let undelegated_pubkeys = base_intent.get_undelegated_pubkeys(); + + let mut execution_report = IntentExecutionReport::default(); + let result = + self.execute_inner(base_intent, &mut execution_report).await; + if !pubkeys.is_empty() { + if result.is_err() { + // We can't know what landed on chain, resync everything + self.ctx + .task_info_fetcher + .reset(ResetType::Specific(&pubkeys)); + } else if !undelegated_pubkeys.is_empty() { + // Only undelegated accounts' nonces become stale. Keep the + // rest cached: a chain re-fetch can race the just-landed + // finalize and reuse a nonce (buffer PDA collision). + self.ctx + .task_info_fetcher + .reset(ResetType::Specific(&undelegated_pubkeys)); + } + } + + // Gather metrics in separate task + let intent_client = self.ctx.intent_client.clone(); + let result = result.inspect(|output| { + let output_copy = *output; + tokio::spawn(async move { + intent_client.intent_metrics(output_copy).await + }); + }); + + let close_buffers = result.is_ok(); + let junk = execution_report.junk; + let result = IntentExecutionResult { + inner: result, + patched_errors: execution_report.patched_errors, + callbacks_report: execution_report.callbacks_report, + #[cfg(feature = "dev-context-only-utils")] + successful_transaction_strategies: execution_report + .successful_transaction_strategies, + }; + let cleanup_handle = CleanupHandle::new( + self.authority, + junk, + close_buffers, + self.ctx.transaction_preparator, + ); + + (result, cleanup_handle) + } +} diff --git a/magicblock-committor-service/src/intent_executor/cleanup_handle.rs b/magicblock-committor-service/src/intent_executor/cleanup_handle.rs new file mode 100644 index 000000000..c10479336 --- /dev/null +++ b/magicblock-committor-service/src/intent_executor/cleanup_handle.rs @@ -0,0 +1,53 @@ +use futures_util::future::join_all; +use solana_keypair::Keypair; + +use crate::{ + tasks::task_strategist::TransactionStrategy, + transaction_preparator::{ + delivery_preparator::BufferExecutionError, TransactionPreparator, + }, +}; + +pub struct CleanupHandle { + authority: Keypair, + junk: Vec, + /// When false (execution failed), only releases ALT reservations without + /// closing buffer PDAs — avoids a race condition where a concurrent + /// executor may still be using them. + close_buffers: bool, + transaction_preparator: T, +} + +impl CleanupHandle { + pub(crate) fn new( + authority: Keypair, + junk: Vec, + close_buffers: bool, + transaction_preparator: T, + ) -> Self { + Self { + junk, + close_buffers, + authority, + transaction_preparator, + } + } + + pub async fn clean(self) -> Result<(), BufferExecutionError> { + let close_buffers = self.close_buffers; + let cleanup_futs = self.junk.iter().map(|to_cleanup| { + self.transaction_preparator.cleanup_for_strategy( + &self.authority, + &to_cleanup.optimized_tasks, + &to_cleanup.lookup_tables_keys, + close_buffers, + ) + }); + + join_all(cleanup_futs) + .await + .into_iter() + .find_map(Result::err) + .map_or(Ok(()), Err) + } +} diff --git a/magicblock-committor-service/src/intent_executor/error.rs b/magicblock-committor-service/src/intent_executor/error.rs index 9477bb2ab..a3eaca0eb 100644 --- a/magicblock-committor-service/src/intent_executor/error.rs +++ b/magicblock-committor-service/src/intent_executor/error.rs @@ -1,22 +1,14 @@ use magicblock_core::traits::ActionError; use magicblock_metrics::metrics; -use magicblock_rpc_client::{ - utils::TransactionErrorMapper, MagicBlockRpcClientError, -}; -use solana_instruction::error::InstructionError; -use solana_rpc_client_api::{ - client_error::{Error as RpcClientError, ErrorKind as RpcClientErrorKind}, - request::{RpcError, RpcRequest}, -}; +use magicblock_rpc_client::MagicBlockRpcClientError; use solana_signature::Signature; use solana_signer::SignerError; -use solana_transaction_error::TransactionError; -use tracing::error; use crate::{ + intent_executor::strategy_executor::error::TransactionStrategyExecutionError, + outbox::outbox_client::InternalOutboxClientError, tasks::{ task_builder::TaskBuilderError, task_strategist::TaskStrategistError, - BaseTaskImpl, }, transaction_preparator::error::TransactionPreparatorError, }; @@ -44,25 +36,10 @@ impl InternalError { } pub fn is_transaction_too_large(&self) -> bool { - fn is_send_transaction_too_large(err: &RpcClientError) -> bool { - matches!(err.request, Some(RpcRequest::SendTransaction)) - && matches!( - &*err.kind, - RpcClientErrorKind::RpcError( - RpcError::RpcResponseError { code: -32602, message, .. } - ) if message.contains("VersionedTransaction too large") - || message.contains("base64 encoded too large") - ) - } - match self { - Self::MagicBlockRpcClientError(err) => match err.as_ref() { - MagicBlockRpcClientError::RpcClientError(err) - | MagicBlockRpcClientError::SendTransaction(err) => { - is_send_transaction_too_large(err) - } - _ => false, - }, + Self::MagicBlockRpcClientError(err) => { + err.is_transaction_too_large() + } Self::SignerError(_) => false, } } @@ -76,7 +53,10 @@ pub enum IntentExecutorError { FailedToFitError, #[error("SignerError: {0}")] SignerError(#[from] SignerError), - // TODO(edwin): remove once proper retries introduced + #[error("OutboxClientError: {0}")] + OutboxClientError(#[from] InternalOutboxClientError), + #[error("Failed to get pending signature status: {0}")] + GetPendingSignatureStatusError(#[source] MagicBlockRpcClientError), #[error("TaskBuilderError: {0}")] TaskBuilderError(#[from] TaskBuilderError), #[error("FailedToCommitError: {err}")] @@ -121,7 +101,8 @@ impl IntentExecutorError { } } - pub fn signatures(&self) -> Option<(Signature, Option)> { + /// Returns signatures of transaction sent to Base layer + pub fn base_signatures(&self) -> Option<(Signature, Option)> { match self { IntentExecutorError::FailedToCommitError { signature, err: _ } => { signature.map(|el| (el, None)) @@ -140,7 +121,9 @@ impl IntentExecutorError { } => commit_signature.map(|el| (el, *finalize_signature)), IntentExecutorError::EmptyIntentError | IntentExecutorError::FailedToFitError - | IntentExecutorError::SignerError(_) => None, + | IntentExecutorError::SignerError(_) + | IntentExecutorError::OutboxClientError(_) + | IntentExecutorError::GetPendingSignatureStatusError(_) => None, } } } @@ -161,233 +144,18 @@ impl metrics::LabelValue for IntentExecutorError { } } -/// Those are the errors that may occur during Commit/Finalize stages on Base layer -#[derive(thiserror::Error, Debug)] -pub enum TransactionStrategyExecutionError { - #[error("User supplied actions are ill-formed: {0}. {1:?}")] - ActionsError(#[source] TransactionError, Option), - #[error("Invalid undelegation: {0}. {1:?}")] - UndelegationError(#[source] TransactionError, Option), - #[error("Accounts committed with an invalid Commit id: {0}. {1:?}")] - CommitIDError(#[source] TransactionError, Option), - #[error("Max instruction trace length exceeded: {0}. {1:?}")] - CpiLimitError(#[source] TransactionError, Option), - #[error("Loaded accounts data size exceeded: {0}. {1:?}")] - LoadedAccountsDataSizeExceeded( - #[source] TransactionError, - Option, - ), - #[error("Unfinalized account error: {0}, {1:?}")] - UnfinalizedAccountError(#[source] TransactionError, Option), - #[error("Transaction too large to send over the wire: {0}")] - TransactionTooLargeError(#[source] InternalError), - #[error("InternalError: {0}")] - InternalError(#[from] InternalError), -} - -impl From for TransactionStrategyExecutionError { - fn from(value: MagicBlockRpcClientError) -> Self { - Self::InternalError(InternalError::from(value)) - } -} - -impl TransactionStrategyExecutionError { - /// Number of compute budget instructions prepended to every transaction. - /// Used to map instruction indices back to task indices. - const TASK_OFFSET: u8 = 2; - - pub fn is_cpi_limit_error(&self) -> bool { - matches!( - self, - Self::CpiLimitError(_, _) - | Self::LoadedAccountsDataSizeExceeded(_, _) - ) - } - - pub fn is_recoverable_by_two_stage(&self) -> bool { - self.is_cpi_limit_error() - || matches!(self, Self::TransactionTooLargeError(_)) - } - - pub fn task_index(&self) -> Option { - match self { - Self::CommitIDError( - TransactionError::InstructionError(index, _), - _, - ) - | Self::ActionsError( - TransactionError::InstructionError(index, _), - _, - ) - | Self::UndelegationError( - TransactionError::InstructionError(index, _), - _, - ) - | Self::UnfinalizedAccountError( - TransactionError::InstructionError(index, _), - _, - ) - | Self::CpiLimitError( - TransactionError::InstructionError(index, _), - _, - ) => index.checked_sub(Self::TASK_OFFSET), - _ => None, - } - } - - pub fn signature(&self) -> Option { - match self { - Self::InternalError(err) => err.signature(), - Self::TransactionTooLargeError(err) => err.signature(), - Self::CommitIDError(_, signature) - | Self::ActionsError(_, signature) - | Self::UndelegationError(_, signature) - | Self::UnfinalizedAccountError(_, signature) - | Self::CpiLimitError(_, signature) - | Self::LoadedAccountsDataSizeExceeded(_, signature) => *signature, - } - } - - /// Convert [`TransactionError`] into known errors that can be handled - /// Otherwise return original [`TransactionError`] - /// [`TransactionStrategyExecutionError`] - pub fn try_from_transaction_error( - err: TransactionError, - signature: Option, - tasks: &[BaseTaskImpl], - ) -> Result { - // Commit Nonce order error - const NONCE_OUT_OF_ORDER: u32 = - dlp_api::error::DlpError::NonceOutOfOrder as u32; - // Errors when commit state already exists - const COMMIT_STATE_INVALID_ACCOUNT_OWNER: u32 = - dlp_api::error::DlpError::CommitStateInvalidAccountOwner as u32; - const COMMIT_STATE_ALREADY_INITIALIZED: u32 = - dlp_api::error::DlpError::CommitStateAlreadyInitialized as u32; - const COMMIT_RECORD_INVALID_ACCOUNT_OWNER: u32 = - dlp_api::error::DlpError::CommitRecordInvalidAccountOwner as u32; - const COMMIT_RECORD_ALREADY_INITIALIZED: u32 = - dlp_api::error::DlpError::CommitRecordAlreadyInitialized as u32; - - match err { - // Some tx may use too much CPIs and we can handle it in certain cases - transaction_err @ TransactionError::InstructionError( - _, - InstructionError::MaxInstructionTraceLengthExceeded, - ) => Ok(TransactionStrategyExecutionError::CpiLimitError( - transaction_err, - signature, - )), - err @ TransactionError::MaxLoadedAccountsDataSizeExceeded => { - Ok(TransactionStrategyExecutionError::LoadedAccountsDataSizeExceeded( - err, - signature, - )) - } - // Map per-task InstructionError into CommitID / Actions / Undelegation errors when possible - TransactionError::InstructionError(index, instruction_err) => { - let tx_err_helper = |instruction_err| -> TransactionError { - TransactionError::InstructionError(index, instruction_err) - }; - let Some(action_index) = index.checked_sub(Self::TASK_OFFSET) - else { - return Err(tx_err_helper(instruction_err)); - }; - - let Some(task) = tasks.get(action_index as usize) else { - return Err(tx_err_helper(instruction_err)); - }; - - match (task, instruction_err) { - ( - BaseTaskImpl::Commit(_) - | BaseTaskImpl::CommitFinalize(_), - instruction_err, - ) => match instruction_err { - InstructionError::Custom(NONCE_OUT_OF_ORDER) => Ok( - TransactionStrategyExecutionError::CommitIDError( - tx_err_helper(InstructionError::Custom( - NONCE_OUT_OF_ORDER, - )), - signature, - ), - ), - instruction_err @ (InstructionError::Custom( - COMMIT_STATE_INVALID_ACCOUNT_OWNER, - ) - | InstructionError::Custom( - COMMIT_STATE_ALREADY_INITIALIZED, - ) - | InstructionError::Custom( - COMMIT_RECORD_INVALID_ACCOUNT_OWNER, - ) - | InstructionError::Custom( - COMMIT_RECORD_ALREADY_INITIALIZED, - )) => { - Ok(TransactionStrategyExecutionError::UnfinalizedAccountError( - tx_err_helper(instruction_err), - signature - )) - } - err => Err(tx_err_helper(err)), - }, - (BaseTaskImpl::BaseAction(_), instruction_err) => { - Ok(TransactionStrategyExecutionError::ActionsError( - tx_err_helper(instruction_err), - signature, - )) - } - (BaseTaskImpl::Undelegate(_), instruction_err) => Ok( - TransactionStrategyExecutionError::UndelegationError( - tx_err_helper(instruction_err), - signature, - ), - ), - (_, instruction_err) => Err(tx_err_helper(instruction_err)), - } - } - // This means transaction failed to other reasons that we don't handle - propagate - err => { - error!(error = ?err, "Message execution failed"); - Err(err) - } - } - } -} - -impl metrics::LabelValue for TransactionStrategyExecutionError { - fn value(&self) -> &str { - match self { - Self::ActionsError(_, _) => "actions_failed", - Self::CpiLimitError(_, _) => "cpi_limit_failed", - Self::LoadedAccountsDataSizeExceeded(_, _) => { - "loaded_accounts_data_limit_exceeded" +impl From<&IntentExecutorError> for ActionError { + fn from(value: &IntentExecutorError) -> Self { + match value { + IntentExecutorError::FailedToCommitError { err, .. } + | IntentExecutorError::FailedToFinalizeError { err, .. } => { + err.into() } - Self::CommitIDError(_, _) => "commit_nonce_failed", - Self::UndelegationError(_, _) => "undelegation_failed", - Self::UnfinalizedAccountError(_, _) => "unfinalized_account_failed", - Self::TransactionTooLargeError(_) => "transaction_too_large", - _ => "failed", + err => ActionError::IntentFailedError(err.to_string()), } } } -pub(crate) struct IntentTransactionErrorMapper<'a> { - pub tasks: &'a [BaseTaskImpl], -} -impl TransactionErrorMapper for IntentTransactionErrorMapper<'_> { - type ExecutionError = TransactionStrategyExecutionError; - fn try_map( - &self, - error: TransactionError, - signature: Option, - ) -> Result { - TransactionStrategyExecutionError::try_from_transaction_error( - error, signature, self.tasks, - ) - } -} - impl From for IntentExecutorError { fn from(value: TaskStrategistError) -> Self { match value { @@ -396,31 +164,6 @@ impl From for IntentExecutorError { } } } - -impl From<&TransactionStrategyExecutionError> for ActionError { - fn from(value: &TransactionStrategyExecutionError) -> Self { - if let TransactionStrategyExecutionError::ActionsError(err, signature) = - value - { - Self::ActionsError(err.clone(), *signature) - } else { - Self::IntentFailedError(value.to_string()) - } - } -} - -impl From<&IntentExecutorError> for ActionError { - fn from(value: &IntentExecutorError) -> Self { - match value { - IntentExecutorError::FailedToCommitError { err, .. } - | IntentExecutorError::FailedToFinalizeError { err, .. } => { - err.into() - } - err => ActionError::IntentFailedError(err.to_string()), - } - } -} - pub type IntentExecutorResult = Result; #[cfg(test)] @@ -433,7 +176,8 @@ mod tests { request::{RpcError, RpcRequest, RpcResponseErrorData}, }; - use super::{InternalError, TransactionStrategyExecutionError}; + use super::InternalError; + use crate::intent_executor::strategy_executor::error::TransactionStrategyExecutionError; const TX_TOO_LARGE_SOLANA: &str = "base64 encoded too large"; const TX_TOO_LARGE_MAGICBLOCK: &str = diff --git a/magicblock-committor-service/src/intent_executor/intent_execution_client.rs b/magicblock-committor-service/src/intent_executor/intent_execution_client.rs index ec0d0169d..54974e3c5 100644 --- a/magicblock-committor-service/src/intent_executor/intent_execution_client.rs +++ b/magicblock-committor-service/src/intent_executor/intent_execution_client.rs @@ -3,24 +3,27 @@ use std::{ops::ControlFlow, time::Duration}; use magicblock_metrics::metrics; use magicblock_rpc_client::{ utils::{ - decide_rpc_error_flow, map_magicblock_client_error, + decide_rpc_error_flow, get_with_retries, map_magicblock_client_error, send_transaction_with_retries, SendErrorMapper, TransactionErrorMapper, }, - MagicBlockRpcClientError, MagicBlockSendTransactionConfig, - MagicBlockSendTransactionOutcome, MagicblockRpcClient, + MagicBlockRpcClientError, MagicBlockRpcClientResult, + MagicBlockSendTransactionConfig, MagicBlockSendTransactionOutcome, + MagicblockRpcClient, }; +use solana_hash::Hash; use solana_keypair::Keypair; use solana_message::VersionedMessage; use solana_rpc_client_api::config::RpcTransactionConfig; use solana_signature::Signature; use solana_transaction::versioned::VersionedTransaction; +use solana_transaction_error::TransactionError; use tracing::warn; use crate::{ intent_executor::{ - error::{ - IntentExecutorResult, IntentTransactionErrorMapper, InternalError, - TransactionStrategyExecutionError, + error::{IntentExecutorResult, InternalError}, + strategy_executor::error::{ + IntentTransactionErrorMapper, TransactionStrategyExecutionError, }, ExecutionOutput, }, @@ -48,45 +51,32 @@ impl IntentExecutionClient { tasks: &[BaseTaskImpl], ) -> IntentExecutorResult { - struct IntentErrorMapper { - transaction_error_mapper: TxMap, - } - impl SendErrorMapper for IntentErrorMapper - where - TxMap: TransactionErrorMapper< - ExecutionError = TransactionStrategyExecutionError, - >, - { - type ExecutionError = TransactionStrategyExecutionError; - fn map(&self, error: InternalError) -> Self::ExecutionError { - if error.is_transaction_too_large() { - return TransactionStrategyExecutionError::TransactionTooLargeError(error); - } - match error { - InternalError::MagicBlockRpcClientError(err) => { - map_magicblock_client_error( - &self.transaction_error_mapper, - *err, - ) - } - err => { - TransactionStrategyExecutionError::InternalError(err) - } - } - } + const RETRY_FOR: Duration = Duration::from_secs(2 * 60); + const MIN_ATTEMPTS: usize = 3; - fn decide_flow( - err: &Self::ExecutionError, - ) -> ControlFlow<(), Duration> { - match err { - TransactionStrategyExecutionError::InternalError( - InternalError::MagicBlockRpcClientError(err), - ) => decide_rpc_error_flow(err), - _ => ControlFlow::Break(()), - } - } - } + // Send with retries + let send_error_mapper = IntentErrorMapper { + transaction_error_mapper: IntentTransactionErrorMapper { tasks }, + }; + let attempt = || async { + self.send_prepared_message(authority, prepared_message.clone()) + .await + }; + send_transaction_with_retries( + attempt, + send_error_mapper, + |i, elapsed| !(elapsed < RETRY_FOR || i < MIN_ATTEMPTS), + ) + .await + } + // Sends signed tx + // Returns success on successfully sent tx, signature can be extracted from tx itself + pub(in crate::intent_executor) async fn send_signed_tx_with_retries( + &self, + transaction: &VersionedTransaction, + tasks: &[BaseTaskImpl], + ) -> Result<(), TransactionStrategyExecutionError> { const RETRY_FOR: Duration = Duration::from_secs(2 * 60); const MIN_ATTEMPTS: usize = 3; @@ -95,14 +85,71 @@ impl IntentExecutionClient { transaction_error_mapper: IntentTransactionErrorMapper { tasks }, }; let attempt = || async { - self.send_prepared_message(authority, prepared_message.clone()) + self.rpc_client + .send_transaction( + transaction, + &MagicBlockSendTransactionConfig::ensure_committed(), + ) .await }; + send_transaction_with_retries( attempt, send_error_mapper, |i, elapsed| !(elapsed < RETRY_FOR || i < MIN_ATTEMPTS), ) + .await?; + Ok(()) + } + + /// Queries the full transaction history for the given signatures. + /// Each entry is `None` if the signature was never included in a block. + /// Use this for restart recovery where txs may be older than the RPC + /// node's recent signature cache. + pub(in crate::intent_executor) async fn get_signature_statuses_with_history( + &self, + signatures: &[Signature], + ) -> MagicBlockRpcClientResult>>> + { + let _timer = metrics::start_rpc_client_signature_history_timer(); + let response = self + .rpc_client + .get_inner() + .get_signature_statuses_with_history(signatures) + .await + .map_err(MagicBlockRpcClientError::from)?; + Ok(response + .value + .into_iter() + .map(|s| s.map(|s| s.err.map_or(Ok(()), Err))) + .collect()) + } + + /// Returns whether `blockhash` is still within its valid window, i.e. + /// whether a transaction built with it could still land. Used to tell + /// apart "not landed yet, may still land" from "guaranteed dead" when + /// a pending signature isn't found on-chain. + pub(in crate::intent_executor) async fn is_blockhash_valid( + &self, + blockhash: &Hash, + ) -> MagicBlockRpcClientResult { + self.rpc_client + .get_inner() + .is_blockhash_valid(blockhash, self.rpc_client.commitment()) + .await + .map_err(MagicBlockRpcClientError::from) + } + + pub(in crate::intent_executor) async fn get_latest_blockhash( + &self, + ) -> MagicBlockRpcClientResult { + const RETRY_FOR: Duration = Duration::from_secs(60); + const MIN_ATTEMPTS: usize = 5; + + let attempt = || async { self.rpc_client.get_latest_blockhash().await }; + get_with_retries(attempt, DefaultGetErrorMapper, |i, elapsed| { + !(elapsed < RETRY_FOR || i < MIN_ATTEMPTS) + }) .await } @@ -193,3 +240,83 @@ impl IntentExecutionClient { } } } + +struct IntentErrorMapper { + transaction_error_mapper: TxMap, +} + +impl SendErrorMapper for IntentErrorMapper +where + TxMap: TransactionErrorMapper< + ExecutionError = TransactionStrategyExecutionError, + >, +{ + type ExecutionError = TransactionStrategyExecutionError; + fn map(&self, error: InternalError) -> Self::ExecutionError { + if error.is_transaction_too_large() { + TransactionStrategyExecutionError::TransactionTooLargeError(error) + } else { + match error { + InternalError::MagicBlockRpcClientError(err) => { + map_magicblock_client_error( + &self.transaction_error_mapper, + *err, + ) + } + err => TransactionStrategyExecutionError::InternalError(err), + } + } + } + + fn decide_flow(err: &Self::ExecutionError) -> ControlFlow<(), Duration> { + match err { + TransactionStrategyExecutionError::InternalError( + InternalError::MagicBlockRpcClientError(err), + ) => decide_rpc_error_flow(err), + _ => ControlFlow::Break(()), + } + } +} + +impl SendErrorMapper + for IntentErrorMapper +where + TxMap: TransactionErrorMapper< + ExecutionError = TransactionStrategyExecutionError, + >, +{ + type ExecutionError = TransactionStrategyExecutionError; + fn map(&self, error: MagicBlockRpcClientError) -> Self::ExecutionError { + if error.is_transaction_too_large() { + TransactionStrategyExecutionError::TransactionTooLargeError( + error.into(), + ) + } else { + map_magicblock_client_error(&self.transaction_error_mapper, error) + } + } + + fn decide_flow(err: &Self::ExecutionError) -> ControlFlow<(), Duration> { + match err { + TransactionStrategyExecutionError::InternalError( + InternalError::MagicBlockRpcClientError(err), + ) => decide_rpc_error_flow(err), + _ => ControlFlow::Break(()), + } + } +} + +struct DefaultGetErrorMapper; +impl SendErrorMapper for DefaultGetErrorMapper { + type ExecutionError = MagicBlockRpcClientError; + + fn map(&self, error: MagicBlockRpcClientError) -> Self::ExecutionError { + error + } + + fn decide_flow( + mapped_error: &Self::ExecutionError, + ) -> ControlFlow<(), Duration> { + decide_rpc_error_flow(mapped_error) + } +} diff --git a/magicblock-committor-service/src/intent_executor/intent_executor_factory.rs b/magicblock-committor-service/src/intent_executor/intent_executor_factory.rs index b6da1104a..acf5491a4 100644 --- a/magicblock-committor-service/src/intent_executor/intent_executor_factory.rs +++ b/magicblock-committor-service/src/intent_executor/intent_executor_factory.rs @@ -1,22 +1,27 @@ use std::{sync::Arc, time::Duration}; use magicblock_core::traits::ActionsCallbackScheduler; +use magicblock_program::outbox_intent_bundles::OutboxIntentBundleStatus; use magicblock_rpc_client::MagicblockRpcClient; use magicblock_table_mania::TableMania; use crate::{ intent_executor::{ - task_info_fetcher::{CacheTaskInfoFetcher, RpcTaskInfoFetcher}, - IntentExecutor, IntentExecutorImpl, + build_stage_intent_executor, error::IntentExecutorError, + intent_execution_client::IntentExecutionClient, IntentExecutor, + IntentExecutorCtx, }, + outbox::OutboxClient, + tasks::task_info_fetcher::{CacheTaskInfoFetcher, RpcTaskInfoFetcher}, transaction_preparator::TransactionPreparatorImpl, - ComputeBudgetConfig, + ComputeBudgetConfig, DEFAULT_ACTIONS_TIMEOUT, }; -pub trait IntentExecutorFactory { - type Executor: IntentExecutor; - - fn create_instance(&self) -> Self::Executor; +pub trait IntentExecutorBuilder { + fn create_instance( + &self, + status: OutboxIntentBundleStatus, + ) -> Box>; } pub struct ExecutorConfig { @@ -25,33 +30,38 @@ pub struct ExecutorConfig { } /// Dummy struct to simplify signature of CommitSchedulerWorker -pub struct IntentExecutorFactoryImpl { +pub struct IntentExecutorBuilderImpl { pub rpc_client: MagicblockRpcClient, pub table_mania: TableMania, pub executor_config: ExecutorConfig, pub task_info_fetcher: Arc>, + pub outbox_client: Arc, pub actions_callback_executor: A, } -impl IntentExecutorFactory for IntentExecutorFactoryImpl +impl IntentExecutorBuilder + for IntentExecutorBuilderImpl where A: ActionsCallbackScheduler, + O: OutboxClient, + O::Error: Into, { - type Executor = - IntentExecutorImpl; - - fn create_instance(&self) -> Self::Executor { + fn create_instance( + &self, + status: OutboxIntentBundleStatus, + ) -> Box> { let transaction_preparator = TransactionPreparatorImpl::new( self.rpc_client.clone(), self.table_mania.clone(), self.executor_config.compute_budget_config.clone(), ); - Self::Executor::new( - self.rpc_client.clone(), + let ctx = IntentExecutorCtx { + intent_client: IntentExecutionClient::new(self.rpc_client.clone()), transaction_preparator, - self.task_info_fetcher.clone(), - self.actions_callback_executor.clone(), - self.executor_config.actions_timeout, - ) + task_info_fetcher: self.task_info_fetcher.clone(), + outbox_client: self.outbox_client.clone(), + actions_callback_executor: self.actions_callback_executor.clone(), + }; + build_stage_intent_executor(ctx, status, DEFAULT_ACTIONS_TIMEOUT) } } diff --git a/magicblock-committor-service/src/intent_executor/mod.rs b/magicblock-committor-service/src/intent_executor/mod.rs index d71c654ef..b47374bab 100644 --- a/magicblock-committor-service/src/intent_executor/mod.rs +++ b/magicblock-committor-service/src/intent_executor/mod.rs @@ -1,62 +1,95 @@ +pub mod accepted_intent_executor; +pub mod cleanup_handle; pub mod error; pub mod intent_execution_client; pub(crate) mod intent_executor_factory; -pub mod single_stage_executor; -pub mod task_info_fetcher; -pub mod two_stage_executor; +pub mod single_stage_intent_executor; +pub mod strategy_executor; +pub mod two_stage_intent_executor; pub mod utils; -use std::{ - sync::Arc, - time::{Duration, Instant}, -}; +use std::{sync::Arc, time::Duration}; use async_trait::async_trait; -use futures_util::future::{join, try_join_all}; use magicblock_core::traits::{ ActionsCallbackScheduler, CallbackScheduleError, }; use magicblock_metrics::metrics; use magicblock_program::{ - magic_scheduled_base_intent::ScheduledIntentBundle, - validator::validator_authority, + magic_scheduled_base_intent::ScheduledIntentBundle, outbox::ExecutionStage, + outbox_intent_bundles::OutboxIntentBundleStatus, }; -use magicblock_rpc_client::MagicblockRpcClient; -use solana_keypair::Keypair; -use solana_pubkey::Pubkey; use solana_signature::Signature; -use solana_signer::Signer; -use tracing::trace; +use strategy_executor::error::TransactionStrategyExecutionError; use crate::{ intent_executor::{ - error::{ - IntentExecutorError, IntentExecutorResult, - TransactionStrategyExecutionError, - }, + accepted_intent_executor::AcceptedIntentExecutor, + cleanup_handle::CleanupHandle, + error::{IntentExecutorError, IntentExecutorResult}, intent_execution_client::IntentExecutionClient, - single_stage_executor::SingleStageExecutor, - task_info_fetcher::{CacheTaskInfoFetcher, ResetType, TaskInfoFetcher}, - two_stage_executor::TwoStageExecutor, - utils::{ - execute_with_timeout, handle_cpi_limit_error, CommitStage, - FinalizeStage, SingleStage, - }, + single_stage_intent_executor::SingleStageIntentExecutor, + two_stage_intent_executor::TwoStageIntentExecutor, }, - persist::{CommitStatus, CommitStatusSignatures, IntentPersister}, + outbox::OutboxClient, tasks::{ - task_builder::{TaskBuilderImpl, TasksBuilder}, - task_strategist::{ - StrategyExecutionMode, TaskStrategist, TransactionStrategy, - }, - }, - transaction_preparator::{ - delivery_preparator::BufferExecutionError, - error::TransactionPreparatorError, TransactionPreparator, + task_info_fetcher::{CacheTaskInfoFetcher, TaskInfoFetcher}, + task_strategist::TransactionStrategy, }, - utils::persist_status_update_by_message_set, + transaction_preparator::TransactionPreparator, }; +#[async_trait] +pub trait IntentExecutor: Send + Sync + 'static { + /// Executes Message on Base layer + /// Returns result of intent execution `IntentExecutionResult` + /// and `CleanupHandle` for cleanup after intent + async fn execute( + self: Box, + base_intent: ScheduledIntentBundle, + ) -> (IntentExecutionResult, CleanupHandle); +} + +pub fn build_stage_intent_executor( + ctx: IntentExecutorCtx, + status: OutboxIntentBundleStatus, + actions_timeout: Duration, +) -> Box> +where + T: TransactionPreparator, + F: TaskInfoFetcher, + A: ActionsCallbackScheduler, + O: OutboxClient, + O::Error: Into, +{ + match status { + OutboxIntentBundleStatus::Accepted => { + Box::new(AcceptedIntentExecutor::new(ctx, actions_timeout)) + as Box + 'static> + } + OutboxIntentBundleStatus::Executing(ExecutionStage::SingleStage( + sig, + )) => { + Box::new(SingleStageIntentExecutor::new(ctx, actions_timeout, sig)) + as Box + 'static> + } + OutboxIntentBundleStatus::Executing(ExecutionStage::TwoStage( + value, + )) => { + Box::new(TwoStageIntentExecutor::new(ctx, actions_timeout, value)) + as Box + 'static> + } + } +} + +pub struct IntentExecutorCtx { + pub intent_client: IntentExecutionClient, + pub transaction_preparator: T, + pub task_info_fetcher: Arc>, + pub outbox_client: Arc, + pub actions_callback_executor: A, +} + #[derive(Clone, Copy, Debug)] pub enum ExecutionOutput { // TODO: with arrival of challenge window remove SingleStage @@ -83,7 +116,6 @@ impl metrics::LabelValue for ExecutionOutput { } } -#[derive(Debug)] pub struct IntentExecutionResult { /// Final result of Intent Execution pub inner: IntentExecutorResult, @@ -91,20 +123,9 @@ pub struct IntentExecutionResult { pub patched_errors: Vec, /// Callbacks result pub callbacks_report: Vec>, -} - -#[async_trait] -pub trait IntentExecutor: Send + Sync + 'static { - /// Executes Message on Base layer - /// Returns `ExecutionOutput` or an `Error` - async fn execute( - &mut self, - base_intent: ScheduledIntentBundle, - persister: Option

, - ) -> IntentExecutionResult; - - /// Cleans up after intent - async fn cleanup(self) -> Result<(), BufferExecutionError>; + #[cfg(feature = "dev-context-only-utils")] + /// Strategies that were successfully executed (test only) + pub successful_transaction_strategies: Vec, } #[derive(Default)] @@ -115,6 +136,9 @@ pub struct IntentExecutionReport { patched_errors: Vec, /// Report of scheduled callbacks callbacks_report: Vec>, + #[cfg(feature = "dev-context-only-utils")] + /// Succeeded transaction strategies report (test only) + successful_transaction_strategies: Vec, } impl IntentExecutionReport { @@ -129,10 +153,16 @@ impl IntentExecutionReport { self.patched_errors.push(value); } - pub fn patched_errors(&self) -> &Vec { + pub fn patched_errors(&self) -> &[TransactionStrategyExecutionError] { &self.patched_errors } + pub fn callbacks_report( + &self, + ) -> &[Result] { + &self.callbacks_report + } + pub fn add_callback_report( &mut self, values: impl IntoIterator>, @@ -143,472 +173,12 @@ impl IntentExecutionReport { pub fn junk(&self) -> &Vec { &self.junk } -} - -pub struct IntentExecutorImpl { - authority: Keypair, - intent_client: IntentExecutionClient, - transaction_preparator: T, - task_info_fetcher: Arc>, - actions_callback_executor: A, - /// Timeout for Intent's actions - actions_timeout: Duration, - - /// Intent execution started at - pub started_at: Instant, - /// Junk that needs to be cleaned up - junk: Vec, - /// Set to false on execution failure so cleanup only releases ALT - /// reservations without closing buffer PDAs (see race condition note in - /// intent_execution_engine) - close_buffers: bool, -} - -impl IntentExecutorImpl -where - T: TransactionPreparator, - F: TaskInfoFetcher, - A: ActionsCallbackScheduler, -{ - pub fn new( - rpc_client: MagicblockRpcClient, - transaction_preparator: T, - task_info_fetcher: Arc>, - actions_callback_executor: A, - actions_timeout: Duration, - ) -> Self { - let authority = validator_authority(); - let intent_client = IntentExecutionClient::new(rpc_client); - Self { - authority, - intent_client, - transaction_preparator, - task_info_fetcher, - actions_callback_executor, - actions_timeout, - - started_at: Instant::now(), - junk: vec![], - close_buffers: true, - } - } - - async fn execute_inner( - &mut self, - intent_bundle: ScheduledIntentBundle, - execution_report: &mut IntentExecutionReport, - persister: &Option

, - ) -> IntentExecutorResult { - if intent_bundle.is_empty() { - return Err(IntentExecutorError::EmptyIntentError); - } - let all_committed_pubkeys = intent_bundle.get_all_committed_pubkeys(); - - // Update tasks status to Pending - { - let update_status = CommitStatus::Pending; - persist_status_update_by_message_set( - persister, - intent_bundle.id, - &all_committed_pubkeys, - update_status, - ); - } - - if all_committed_pubkeys.is_empty() { - // Build tasks for commit stage - // TODO (snawaz): it's actually MagicBaseIntent::BaseActions scenario, not Commit - // scenario, so the related code needs little bit of refactoring and proper renaming. - let commit_tasks = TaskBuilderImpl::commit_tasks( - &self.task_info_fetcher, - &intent_bundle, - persister, - ) - .await?; - - // Standalone actions executed in single stage - let mut strategy = TaskStrategist::build_strategy( - commit_tasks, - &self.authority.pubkey(), - persister, - )?; - strategy.standalone_action_nonce = Some(intent_bundle.id); - return self - .single_stage_execution_flow( - intent_bundle, - strategy, - execution_report, - persister, - ) - .await; - }; - - // Build tasks for commit & finalize stages - let (commit_tasks, finalize_tasks) = { - let commit_tasks_fut = TaskBuilderImpl::commit_tasks( - &self.task_info_fetcher, - &intent_bundle, - persister, - ); - let finalize_tasks_fut = TaskBuilderImpl::finalize_tasks( - &self.task_info_fetcher, - &intent_bundle, - ); - let (commit_tasks, finalize_tasks) = - join(commit_tasks_fut, finalize_tasks_fut).await; - - (commit_tasks?, finalize_tasks?) - }; - - // Build execution strategy - match TaskStrategist::build_execution_strategy( - commit_tasks, - finalize_tasks, - &self.authority.pubkey(), - persister, - )? { - StrategyExecutionMode::SingleStage(strategy) => { - trace!("Single stage execution"); - self.single_stage_execution_flow( - intent_bundle, - strategy, - execution_report, - persister, - ) - .await - } - StrategyExecutionMode::TwoStage { - commit_stage, - finalize_stage, - } => { - trace!("Two stage execution"); - self.two_stage_execution_flow( - &all_committed_pubkeys, - commit_stage, - finalize_stage, - execution_report, - persister, - ) - .await - } - } - } - - fn time_left(&self) -> Option { - self.actions_timeout.checked_sub(self.started_at.elapsed()) - } - /// Starting execution from single stage - pub async fn single_stage_execution_flow( + #[cfg(feature = "dev-context-only-utils")] + pub fn add_succeeded_transaction_strategy( &mut self, - base_intent: ScheduledIntentBundle, - transaction_strategy: TransactionStrategy, - execution_report: &mut IntentExecutionReport, - persister: &Option

, - ) -> IntentExecutorResult { - let committed_pubkeys = base_intent.get_all_committed_pubkeys(); - - let mut single_stage_executor = SingleStageExecutor::new( - self.authority.insecure_clone(), - self.intent_client.clone(), - self.task_info_fetcher.clone(), - transaction_strategy, - self.actions_callback_executor.clone(), - execution_report, - ); - let res = execute_with_timeout( - self.time_left(), - SingleStage { - inner: &mut single_stage_executor, - transaction_preparator: &self.transaction_preparator, - committed_pubkeys: &committed_pubkeys, - }, - persister, - ) - .await; - - // Here we continue only IF the error is a limit-type execution error - // We can recover that Error by splitting execution - // in 2 stages - commit & finalize - // Otherwise we return error - let execution_err = match res { - Err(IntentExecutorError::FailedToFinalizeError { - err, - commit_signature: _, - finalize_signature: _, - }) if !committed_pubkeys.is_empty() - && err.is_recoverable_by_two_stage() => - { - err - } - res => { - let signature = res.as_ref().ok().copied(); - single_stage_executor - .execute_callbacks(signature, res.as_ref().map(|_| ())); - let transaction_strategy = - single_stage_executor.consume_strategy(); - execution_report.dispose(transaction_strategy); - return res.map(ExecutionOutput::SingleStage); - } - }; - - // With actions, we can't predict num of CPIs - // If we get here we will try to switch from Single stage to Two Stage commit - // Note that this not necessarily will pass at the end due to the same reason - let strategy = single_stage_executor.consume_strategy(); - let (commit_strategy, finalize_strategy, cleanup) = - handle_cpi_limit_error(&self.authority.pubkey(), strategy); - execution_report.dispose(cleanup); - execution_report.add_patched_error(execution_err); - - self.two_stage_execution_flow( - &committed_pubkeys, - commit_strategy, - finalize_strategy, - execution_report, - persister, - ) - .await - } - - pub async fn two_stage_execution_flow( - &mut self, - committed_pubkeys: &[Pubkey], - commit_strategy: TransactionStrategy, - finalize_strategy: TransactionStrategy, - execution_report: &mut IntentExecutionReport, - persister: &Option

, - ) -> IntentExecutorResult { - let mut executor = TwoStageExecutor::new( - self.authority.insecure_clone(), - commit_strategy, - finalize_strategy, - self.intent_client.clone(), - self.actions_callback_executor.clone(), - execution_report, - ); - - let commit_signature = execute_with_timeout( - self.time_left(), - CommitStage { - inner: &mut executor, - transaction_preparator: &self.transaction_preparator, - task_info_fetcher: &self.task_info_fetcher, - committed_pubkeys, - }, - persister, - ) - .await?; - - let mut finalize_executor = executor.done(commit_signature); - let finalize_signature = execute_with_timeout( - self.time_left(), - FinalizeStage { - inner: &mut finalize_executor, - transaction_preparator: &self.transaction_preparator, - }, - persister, - ) - .await?; - - let finalized_stage = finalize_executor.done(finalize_signature); - Ok(ExecutionOutput::TwoStage { - commit_signature: finalized_stage.commit_signature, - finalize_signature: finalized_stage.finalize_signature, - }) - } - - /// Flushes result into presistor - /// The result will be propagated down to callers - fn persist_result( - persistor: &P, - result: &IntentExecutorResult, - message_id: u64, - pubkeys: &[Pubkey], + value: TransactionStrategy, ) { - let update_status = match result { - Ok(value) => { - let signatures = match *value { - ExecutionOutput::SingleStage(signature) => { - CommitStatusSignatures { - commit_stage_signature: signature, - finalize_stage_signature: Some(signature), - } - } - ExecutionOutput::TwoStage { - commit_signature, - finalize_signature, - } => CommitStatusSignatures { - commit_stage_signature: commit_signature, - finalize_stage_signature: Some(finalize_signature), - }, - }; - let update_status = CommitStatus::Succeeded(signatures); - persist_status_update_by_message_set( - persistor, - message_id, - pubkeys, - update_status, - ); - - if let Err(err) = - persistor.finalize_base_intent(message_id, *value) - { - tracing::error!(error = ?err, "Failed to persist ExecutionOutput"); - } - - return; - } - Err(IntentExecutorError::EmptyIntentError) - | Err(IntentExecutorError::FailedToFitError) - | Err(IntentExecutorError::TaskBuilderError(_)) - | Err(IntentExecutorError::FailedCommitPreparationError( - TransactionPreparatorError::SignerError(_), - )) - | Err(IntentExecutorError::FailedFinalizePreparationError( - TransactionPreparatorError::SignerError(_), - )) => Some(CommitStatus::Failed), - Err(IntentExecutorError::FailedCommitPreparationError( - TransactionPreparatorError::FailedToFitError, - )) => Some(CommitStatus::PartOfTooLargeBundleToProcess), - Err(IntentExecutorError::FailedCommitPreparationError( - TransactionPreparatorError::DeliveryPreparationError(_), - )) => { - // Intermediate commit preparation progress recorded by DeliveryPreparator - None - } - Err(IntentExecutorError::FailedToCommitError { - err: _, - signature, - }) => { - // Commit is a single TX, so if it fails, all of commited accounts marked FailedProcess - let status_signature = - signature.map(|sig| CommitStatusSignatures { - commit_stage_signature: sig, - finalize_stage_signature: None, - }); - Some(CommitStatus::FailedProcess(status_signature)) - } - Err(IntentExecutorError::FailedFinalizePreparationError(_)) => { - // Not supported in persistor - None - } - Err(IntentExecutorError::FailedToFinalizeError { - err: _, - commit_signature, - finalize_signature, - }) => { - // Finalize is a single TX, so if it fails, all of commited accounts marked FailedFinalize - let update_status = - if let Some(commit_signature) = commit_signature { - let signatures = CommitStatusSignatures { - commit_stage_signature: *commit_signature, - finalize_stage_signature: *finalize_signature, - }; - CommitStatus::FailedFinalize(signatures) - } else { - CommitStatus::FailedProcess(None) - }; - - Some(update_status) - } - Err(IntentExecutorError::SignerError(_)) => { - Some(CommitStatus::Failed) - } - }; - - if let Some(update_status) = update_status { - persist_status_update_by_message_set( - persistor, - message_id, - pubkeys, - update_status, - ); - } - } -} - -#[async_trait] -impl IntentExecutor for IntentExecutorImpl -where - T: TransactionPreparator, - C: TaskInfoFetcher, - A: ActionsCallbackScheduler, -{ - /// Executes Message on Base layer - /// Returns `ExecutionOutput` or an `Error` - async fn execute( - &mut self, - base_intent: ScheduledIntentBundle, - persister: Option

, - ) -> IntentExecutionResult { - self.started_at = Instant::now(); - let message_id = base_intent.id; - let pubkeys = base_intent.get_all_committed_pubkeys(); - let undelegated_pubkeys: Vec = base_intent - .intent_bundle - .get_undelegate_intent_pubkeys() - .into_iter() - .chain( - base_intent - .intent_bundle - .get_commit_finalize_and_undelegate_intent_pubkeys(), - ) - .flatten() - .collect(); - - let mut execution_report = IntentExecutionReport::default(); - let result = self - .execute_inner(base_intent, &mut execution_report, &persister) - .await; - if !pubkeys.is_empty() { - // Reset TaskInfoFetcher, as cache could become invalid - if result.is_err() { - // We can't know what landed on chain, resync everything - self.task_info_fetcher.reset(ResetType::Specific(&pubkeys)); - } else if !undelegated_pubkeys.is_empty() { - // Only undelegated accounts' nonces become stale. Keep the - // rest cached: a chain re-fetch can race the just-landed - // finalize and reuse a nonce (buffer PDA collision). - // NOTE: if undelegation was removed - we still reset - // We assume its safe since all consecutive commits will fail - self.task_info_fetcher - .reset(ResetType::Specific(&undelegated_pubkeys)); - } - - // Write result of intent into Persister - Self::persist_result(&persister, &result, message_id, &pubkeys); - } - - // Gather metrics in separate task - let intent_client = self.intent_client.clone(); - let result = result.inspect(|output| { - let output_copy = *output; - tokio::spawn(async move { - intent_client.intent_metrics(output_copy).await - }); - }); - - self.close_buffers = result.is_ok(); - self.junk = execution_report.junk; - IntentExecutionResult { - inner: result, - patched_errors: execution_report.patched_errors, - callbacks_report: execution_report.callbacks_report, - } - } - - async fn cleanup(mut self) -> Result<(), BufferExecutionError> { - let close_buffers = self.close_buffers; - let cleanup_futs = self.junk.iter().map(|to_cleanup| { - self.transaction_preparator.cleanup_for_strategy( - &self.authority, - &to_cleanup.optimized_tasks, - &to_cleanup.lookup_tables_keys, - close_buffers, - ) - }); - - try_join_all(cleanup_futs).await.map(|_| ()) + self.successful_transaction_strategies.push(value); } } diff --git a/magicblock-committor-service/src/intent_executor/single_stage_executor.rs b/magicblock-committor-service/src/intent_executor/single_stage_executor.rs deleted file mode 100644 index 3b9dddfce..000000000 --- a/magicblock-committor-service/src/intent_executor/single_stage_executor.rs +++ /dev/null @@ -1,305 +0,0 @@ -use std::{ops::ControlFlow, sync::Arc}; - -use magicblock_core::traits::{ActionError, ActionsCallbackScheduler}; -use solana_keypair::Keypair; -use solana_pubkey::Pubkey; -use solana_signature::Signature; -use solana_signer::Signer; -use tracing::{error, instrument}; - -use crate::{ - intent_executor::{ - error::{ - IntentExecutorError, IntentExecutorResult, - TransactionStrategyExecutionError, - }, - intent_execution_client::IntentExecutionClient, - task_info_fetcher::{CacheTaskInfoFetcher, TaskInfoFetcher}, - utils::{ - handle_actions_result, handle_commit_id_error, - handle_undelegation_error, prepare_and_execute_strategy, - }, - IntentExecutionReport, - }, - persist::{IntentPersister, IntentPersisterImpl}, - tasks::{task_strategist::TransactionStrategy, BaseTaskImpl, FinalizeTask}, - transaction_preparator::TransactionPreparator, -}; - -pub struct SingleStageExecutor<'a, F, A> { - current_attempt: u8, - execution_report: &'a mut IntentExecutionReport, - - authority: Keypair, - intent_client: IntentExecutionClient, - task_info_fetcher: Arc>, - callback_scheduler: A, - transaction_strategy: TransactionStrategy, -} - -impl<'a, F, A> SingleStageExecutor<'a, F, A> -where - F: TaskInfoFetcher, - A: ActionsCallbackScheduler, -{ - pub fn new( - authority: Keypair, - intent_client: IntentExecutionClient, - task_info_fetcher: Arc>, - transaction_strategy: TransactionStrategy, - callback_scheduler: A, - execution_report: &'a mut IntentExecutionReport, - ) -> Self { - Self { - current_attempt: 0, - authority, - intent_client, - task_info_fetcher, - transaction_strategy, - callback_scheduler, - execution_report, - } - } - - #[instrument( - skip(self, committed_pubkeys, transaction_preparator, persister), - fields(stage = "single_stage") - )] - pub async fn execute( - &mut self, - committed_pubkeys: &[Pubkey], - transaction_preparator: &T, - persister: &Option

, - ) -> IntentExecutorResult - where - T: TransactionPreparator, - P: IntentPersister, - { - const RECURSION_CEILING: u8 = 10; - - let result = loop { - self.current_attempt += 1; - - // Prepare & execute message - let execution_result = prepare_and_execute_strategy( - &self.intent_client, - &self.authority, - transaction_preparator, - &mut self.transaction_strategy, - persister, - ) - .await - .map_err(IntentExecutorError::FailedFinalizePreparationError)?; - - // Process error: Ok - return, Err - handle further - let execution_err = match execution_result { - // break with result, strategy that was executed at this point has to be returned for cleanup - Ok(value) => { - break Ok(value); - } - Err(err) => err, - }; - - // Attempt patching - let flow = self - .patch_strategy( - &execution_err, - committed_pubkeys, - transaction_preparator, - ) - .await?; - let cleanup = match flow { - ControlFlow::Continue(cleanup) => cleanup, - ControlFlow::Break(()) => { - break Err(execution_err); - } - }; - self.intent_client.invalidate_cached_blockhash().await; - self.execution_report.dispose(cleanup); - - if self.current_attempt >= RECURSION_CEILING { - error!( - attempt = self.current_attempt, - ceiling = RECURSION_CEILING, - error = ?execution_err, - "Recursion ceiling exceeded" - ); - break Err(execution_err); - } else { - self.execution_report.add_patched_error(execution_err); - } - }; - - result.map_err(|err| { - IntentExecutorError::from_finalize_execution_error( - err, - // TODO(edwin): shall one stage have same signature for commit & finalize - None, - ) - }) - } - - pub fn has_callbacks(&self) -> bool { - self.transaction_strategy.has_actions_callbacks() - } - - pub fn execute_callbacks( - &mut self, - signature: Option, - result: Result<(), impl Into>, - ) { - let junk_strategy = handle_actions_result( - &self.authority.pubkey(), - &self.callback_scheduler, - self.execution_report, - &mut self.transaction_strategy, - signature, - result.map_err(|err| err.into()), - ); - self.execution_report.dispose(junk_strategy); - } - - pub fn consume_strategy(self) -> TransactionStrategy { - self.transaction_strategy - } - - /// Patch the current `transaction_strategy` in response to a recoverable - /// [`TransactionStrategyExecutionError`], optionally preparing cleanup data - /// to be applied after a retry. - /// - /// [`TransactionStrategyExecutionError`], returning either: - /// - `Continue(to_cleanup)` when a retry should be attempted with cleanup metadata, or - /// - `Break(())` when this stage cannot be recovered here. - pub async fn patch_strategy( - &mut self, - err: &TransactionStrategyExecutionError, - committed_pubkeys: &[Pubkey], - transaction_preparator: &T, - ) -> IntentExecutorResult> { - if committed_pubkeys.is_empty() { - // No patching is applicable if intent doesn't commit accounts - return Ok(ControlFlow::Break(())); - } - - match err { - TransactionStrategyExecutionError::ActionsError(err, signature) => { - // Here we patch strategy for it to be retried in next iteration - // & we also record data that has to be cleaned up after patch - let action_error = Err(ActionError::ActionsError(err.clone(), *signature)); - let to_cleanup = handle_actions_result( - &self.authority.pubkey(), - &self.callback_scheduler, - self.execution_report, - &mut self.transaction_strategy, - *signature, - action_error, - ); - Ok(ControlFlow::Continue(to_cleanup)) - } - TransactionStrategyExecutionError::CommitIDError(_, _) => { - // Here we patch strategy for it to be retried in next iteration - // & we also record data that has to be cleaned up after patch - let to_cleanup = handle_commit_id_error( - &self.authority.pubkey(), - &self.task_info_fetcher, - committed_pubkeys, - &mut self.transaction_strategy, - ) - .await?; - Ok(ControlFlow::Continue(to_cleanup)) - } - err - @ TransactionStrategyExecutionError::UnfinalizedAccountError( - _, - signature, - ) => { - let optimized_tasks = - self.transaction_strategy.optimized_tasks.as_slice(); - if let Some(delegated_account) = err - .task_index() - .and_then(|index| optimized_tasks.get(index as usize)) - .and_then(|task| match task { - BaseTaskImpl::Commit(task) => { - Some(task.committed_account.pubkey) - } - BaseTaskImpl::CommitFinalize(task) => { - Some(task.committed_account.pubkey) - } - _ => None, - }) - { - self.handle_unfinalized_account_error( - signature, - delegated_account, - transaction_preparator, - ) - .await - } else { - error!( - task_index = err.task_index(), - optimized_tasks_len = optimized_tasks.len(), - error = ?err, - "RPC returned unexpected task index" - ); - Ok(ControlFlow::Break(())) - } - } - TransactionStrategyExecutionError::UndelegationError(_, _) => { - // Here we patch strategy for it to be retried in next iteration - // & we also record data that has to be cleaned up after patch - let to_cleanup = handle_undelegation_error( - &self.authority.pubkey(), - &mut self.transaction_strategy - ); - Ok(ControlFlow::Continue(to_cleanup)) - } - TransactionStrategyExecutionError::CpiLimitError(_, _) - | TransactionStrategyExecutionError::LoadedAccountsDataSizeExceeded(_, _) - | TransactionStrategyExecutionError::TransactionTooLargeError(_) => { - // Can't be handled in scope of single stage execution - // We signal flow break - Ok(ControlFlow::Break(())) - } - TransactionStrategyExecutionError::InternalError(_) => { - // Error that we can't handle - break with cleanup data - Ok(ControlFlow::Break(())) - } - } - } - - /// Handles unfinalized account error - /// Sends a separate tx to finalize account and then continues execution - async fn handle_unfinalized_account_error( - &self, - failed_signature: &Option, - delegated_account: Pubkey, - transaction_preparator: &T, - ) -> IntentExecutorResult> { - let finalize_task: BaseTaskImpl = - FinalizeTask { delegated_account }.into(); - prepare_and_execute_strategy( - &self.intent_client, - &self.authority, - transaction_preparator, - &mut TransactionStrategy { - optimized_tasks: vec![finalize_task], - lookup_tables_keys: vec![], - standalone_action_nonce: None, - }, - &None::, - ) - .await - .map_err(IntentExecutorError::FailedFinalizePreparationError)? - .map_err(|err| IntentExecutorError::FailedToFinalizeError { - err, - commit_signature: None, - finalize_signature: *failed_signature, - })?; - - Ok(ControlFlow::Continue(TransactionStrategy { - optimized_tasks: vec![], - lookup_tables_keys: vec![], - standalone_action_nonce: None, - })) - } -} diff --git a/magicblock-committor-service/src/intent_executor/single_stage_intent_executor.rs b/magicblock-committor-service/src/intent_executor/single_stage_intent_executor.rs new file mode 100644 index 000000000..3090c86da --- /dev/null +++ b/magicblock-committor-service/src/intent_executor/single_stage_intent_executor.rs @@ -0,0 +1,193 @@ +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use magicblock_core::traits::ActionsCallbackScheduler; +use magicblock_program::{ + magic_scheduled_base_intent::ScheduledIntentBundle, + outbox::PendingTransaction, validator::validator_authority, +}; +use solana_keypair::Keypair; +use solana_signer::Signer; + +use crate::{ + intent_executor::{ + cleanup_handle::CleanupHandle, + error::{IntentExecutorError, IntentExecutorResult}, + strategy_executor::utils::resolve_pending_signature, + utils::{build_commit_finalize_tasks, execute_single_stage_flow}, + ExecutionOutput, IntentExecutionReport, IntentExecutionResult, + IntentExecutor, IntentExecutorCtx, + }, + outbox::{OutboxClient, ScheduledBaseIntentMeta}, + tasks::{ + task_info_fetcher::{ResetType, TaskInfoFetcher}, + task_strategist::TaskStrategist, + }, + transaction_preparator::TransactionPreparator, +}; + +pub struct SingleStageIntentExecutor { + authority: Keypair, + /// data of pending stage + pending_transaction: PendingTransaction, + /// Intent Executor context + ctx: IntentExecutorCtx, + + /// Timeout for Intent's actions + pub actions_timeout: Duration, + /// Intent execution started at + pub started_at: Instant, +} + +impl SingleStageIntentExecutor +where + T: TransactionPreparator, + F: TaskInfoFetcher, + A: ActionsCallbackScheduler, + O: OutboxClient, + O::Error: Into, +{ + pub fn new( + ctx: IntentExecutorCtx, + actions_timeout: Duration, + pending_transaction: PendingTransaction, + ) -> Self { + let authority = validator_authority(); + Self { + authority, + pending_transaction, + ctx, + + actions_timeout, + started_at: Instant::now(), + } + } + + fn time_left(&self) -> Option { + self.actions_timeout.checked_sub(self.started_at.elapsed()) + } + + pub async fn execute_inner( + &mut self, + intent_bundle: ScheduledIntentBundle, + execution_report: &mut IntentExecutionReport, + ) -> IntentExecutorResult { + let succeeded = resolve_pending_signature( + &self.ctx.intent_client, + &self.pending_transaction, + ) + .await?; + match succeeded { + // Intent was already executed on a previous run - notify the + // outbox so it isn't left pending and rediscovered again. + true => { + let output = Ok(ExecutionOutput::SingleStage( + self.pending_transaction.signature, + )); + self.ctx + .outbox_client + .notify_commit_sent( + ScheduledBaseIntentMeta::new(&intent_bundle), + &output, + execution_report, + ) + .await + .map_err(Into::into)?; + + output + } + false => { + // It we're here so previous run determined this should be single stage + let (commit_tasks, finalize_tasks) = + build_commit_finalize_tasks( + &intent_bundle, + &self.ctx.task_info_fetcher, + ) + .await?; + + let single_stage_tasks = + [commit_tasks, finalize_tasks].concat(); + let transaction_strategy = TaskStrategist::build_strategy( + single_stage_tasks, + &self.authority.pubkey(), + )?; + + execute_single_stage_flow( + &self.ctx, + &self.authority, + intent_bundle, + transaction_strategy, + execution_report, + || self.time_left(), + ) + .await + } + } + } +} + +#[async_trait] +impl IntentExecutor for SingleStageIntentExecutor +where + T: TransactionPreparator, + F: TaskInfoFetcher, + A: ActionsCallbackScheduler, + O: OutboxClient, + O::Error: Into, +{ + async fn execute( + mut self: Box, + base_intent: ScheduledIntentBundle, + ) -> (IntentExecutionResult, CleanupHandle) { + self.started_at = Instant::now(); + let pubkeys = base_intent.get_all_committed_pubkeys(); + let undelegated_pubkeys = base_intent.get_undelegated_pubkeys(); + + let mut execution_report = IntentExecutionReport::default(); + let result = + self.execute_inner(base_intent, &mut execution_report).await; + if !pubkeys.is_empty() { + if result.is_err() { + // We can't know what landed on chain, resync everything + self.ctx + .task_info_fetcher + .reset(ResetType::Specific(&pubkeys)); + } else if !undelegated_pubkeys.is_empty() { + // Only undelegated accounts' nonces become stale. Keep the + // rest cached: a chain re-fetch can race the just-landed + // finalize and reuse a nonce (buffer PDA collision). + self.ctx + .task_info_fetcher + .reset(ResetType::Specific(&undelegated_pubkeys)); + } + } + + // Gather metrics in separate task + let intent_client = self.ctx.intent_client.clone(); + let result = result.inspect(|output| { + let output_copy = *output; + tokio::spawn(async move { + intent_client.intent_metrics(output_copy).await + }); + }); + + let close_buffers = result.is_ok(); + let junk = execution_report.junk; + let result = IntentExecutionResult { + inner: result, + patched_errors: execution_report.patched_errors, + callbacks_report: execution_report.callbacks_report, + #[cfg(feature = "dev-context-only-utils")] + successful_transaction_strategies: execution_report + .successful_transaction_strategies, + }; + let cleanup_handle = CleanupHandle::new( + self.authority, + junk, + close_buffers, + self.ctx.transaction_preparator, + ); + + (result, cleanup_handle) + } +} diff --git a/magicblock-committor-service/src/intent_executor/strategy_executor/error.rs b/magicblock-committor-service/src/intent_executor/strategy_executor/error.rs new file mode 100644 index 000000000..c12d5e1c1 --- /dev/null +++ b/magicblock-committor-service/src/intent_executor/strategy_executor/error.rs @@ -0,0 +1,251 @@ +use magicblock_core::traits::ActionError; +use magicblock_metrics::metrics; +use magicblock_rpc_client::{ + utils::TransactionErrorMapper, MagicBlockRpcClientError, +}; +use solana_instruction::error::InstructionError; +use solana_signature::Signature; +use solana_transaction_error::TransactionError; +use tracing::error; + +use crate::{intent_executor::error::InternalError, tasks::BaseTaskImpl}; + +/// Those are the errors that may occur during Commit/Finalize stages on Base layer +#[derive(thiserror::Error, Debug)] +pub enum TransactionStrategyExecutionError { + #[error("User supplied actions are ill-formed: {0}. {1:?}")] + ActionsError(#[source] TransactionError, Option), + #[error("Invalid undelegation: {0}. {1:?}")] + UndelegationError(#[source] TransactionError, Option), + #[error("Accounts committed with an invalid Commit id: {0}. {1:?}")] + CommitIDError(#[source] TransactionError, Option), + #[error("Max instruction trace length exceeded: {0}. {1:?}")] + CpiLimitError(#[source] TransactionError, Option), + #[error("Loaded accounts data size exceeded: {0}. {1:?}")] + LoadedAccountsDataSizeExceeded( + #[source] TransactionError, + Option, + ), + #[error("Unfinalized account error: {0}, {1:?}")] + UnfinalizedAccountError(#[source] TransactionError, Option), + #[error("Transaction too large to send over the wire: {0}")] + TransactionTooLargeError(#[source] InternalError), + #[error("InternalError: {0}")] + InternalError(#[from] InternalError), +} + +impl From for TransactionStrategyExecutionError { + fn from(value: MagicBlockRpcClientError) -> Self { + Self::InternalError(InternalError::from(value)) + } +} + +impl TransactionStrategyExecutionError { + /// Number of compute budget instructions prepended to every transaction. + /// Used to map instruction indices back to task indices. + const TASK_OFFSET: u8 = 2; + + pub fn is_cpi_limit_error(&self) -> bool { + matches!( + self, + Self::CpiLimitError(_, _) + | Self::LoadedAccountsDataSizeExceeded(_, _) + ) + } + + pub fn is_recoverable_by_two_stage(&self) -> bool { + self.is_cpi_limit_error() + || matches!(self, Self::TransactionTooLargeError(_)) + } + + pub fn task_index(&self) -> Option { + match self { + Self::CommitIDError( + TransactionError::InstructionError(index, _), + _, + ) + | Self::ActionsError( + TransactionError::InstructionError(index, _), + _, + ) + | Self::UndelegationError( + TransactionError::InstructionError(index, _), + _, + ) + | Self::UnfinalizedAccountError( + TransactionError::InstructionError(index, _), + _, + ) + | Self::CpiLimitError( + TransactionError::InstructionError(index, _), + _, + ) => index.checked_sub(Self::TASK_OFFSET), + _ => None, + } + } + + pub fn signature(&self) -> Option { + match self { + Self::InternalError(err) => err.signature(), + Self::TransactionTooLargeError(err) => err.signature(), + Self::CommitIDError(_, signature) + | Self::ActionsError(_, signature) + | Self::UndelegationError(_, signature) + | Self::UnfinalizedAccountError(_, signature) + | Self::CpiLimitError(_, signature) + | Self::LoadedAccountsDataSizeExceeded(_, signature) => *signature, + } + } + + /// Convert [`TransactionError`] into known errors that can be handled + /// Otherwise return original [`TransactionError`] + /// [`TransactionStrategyExecutionError`] + pub fn try_from_transaction_error( + err: TransactionError, + signature: Option, + tasks: &[BaseTaskImpl], + ) -> Result { + // Commit Nonce order error + const NONCE_OUT_OF_ORDER: u32 = + dlp_api::error::DlpError::NonceOutOfOrder as u32; + // Errors when commit state already exists + const COMMIT_STATE_INVALID_ACCOUNT_OWNER: u32 = + dlp_api::error::DlpError::CommitStateInvalidAccountOwner as u32; + const COMMIT_STATE_ALREADY_INITIALIZED: u32 = + dlp_api::error::DlpError::CommitStateAlreadyInitialized as u32; + const COMMIT_RECORD_INVALID_ACCOUNT_OWNER: u32 = + dlp_api::error::DlpError::CommitRecordInvalidAccountOwner as u32; + const COMMIT_RECORD_ALREADY_INITIALIZED: u32 = + dlp_api::error::DlpError::CommitRecordAlreadyInitialized as u32; + + match err { + // Some tx may use too much CPIs and we can handle it in certain cases + transaction_err @ TransactionError::InstructionError( + _, + InstructionError::MaxInstructionTraceLengthExceeded, + ) => Ok(TransactionStrategyExecutionError::CpiLimitError( + transaction_err, + signature, + )), + err @ TransactionError::MaxLoadedAccountsDataSizeExceeded => { + Ok(TransactionStrategyExecutionError::LoadedAccountsDataSizeExceeded( + err, + signature, + )) + } + // Map per-task InstructionError into CommitID / Actions / Undelegation errors when possible + TransactionError::InstructionError(index, instruction_err) => { + let tx_err_helper = |instruction_err| -> TransactionError { + TransactionError::InstructionError(index, instruction_err) + }; + let Some(action_index) = index.checked_sub(Self::TASK_OFFSET) + else { + return Err(tx_err_helper(instruction_err)); + }; + + let Some(task) = tasks.get(action_index as usize) else { + return Err(tx_err_helper(instruction_err)); + }; + + match (task, instruction_err) { + ( + BaseTaskImpl::Commit(_) + | BaseTaskImpl::CommitFinalize(_), + instruction_err, + ) => match instruction_err { + InstructionError::Custom(NONCE_OUT_OF_ORDER) => Ok( + TransactionStrategyExecutionError::CommitIDError( + tx_err_helper(InstructionError::Custom( + NONCE_OUT_OF_ORDER, + )), + signature, + ), + ), + instruction_err @ (InstructionError::Custom( + COMMIT_STATE_INVALID_ACCOUNT_OWNER, + ) + | InstructionError::Custom( + COMMIT_STATE_ALREADY_INITIALIZED, + ) + | InstructionError::Custom( + COMMIT_RECORD_INVALID_ACCOUNT_OWNER, + ) + | InstructionError::Custom( + COMMIT_RECORD_ALREADY_INITIALIZED, + )) => { + Ok(TransactionStrategyExecutionError::UnfinalizedAccountError( + tx_err_helper(instruction_err), + signature + )) + } + err => Err(tx_err_helper(err)), + }, + (BaseTaskImpl::BaseAction(_), instruction_err) => { + Ok(TransactionStrategyExecutionError::ActionsError( + tx_err_helper(instruction_err), + signature, + )) + } + (BaseTaskImpl::Undelegate(_), instruction_err) => Ok( + TransactionStrategyExecutionError::UndelegationError( + tx_err_helper(instruction_err), + signature, + ), + ), + (_, instruction_err) => Err(tx_err_helper(instruction_err)), + } + } + // This means transaction failed to other reasons that we don't handle - propagate + err => { + error!(error = ?err, "Message execution failed"); + Err(err) + } + } + } +} + +impl metrics::LabelValue for TransactionStrategyExecutionError { + fn value(&self) -> &str { + match self { + Self::ActionsError(_, _) => "actions_failed", + Self::CpiLimitError(_, _) => "cpi_limit_failed", + Self::LoadedAccountsDataSizeExceeded(_, _) => { + "loaded_accounts_data_limit_exceeded" + } + Self::CommitIDError(_, _) => "commit_nonce_failed", + Self::UndelegationError(_, _) => "undelegation_failed", + Self::UnfinalizedAccountError(_, _) => "unfinalized_account_failed", + Self::TransactionTooLargeError(_) => "transaction_too_large", + _ => "failed", + } + } +} + +pub(crate) struct IntentTransactionErrorMapper<'a> { + pub tasks: &'a [BaseTaskImpl], +} + +impl TransactionErrorMapper for IntentTransactionErrorMapper<'_> { + type ExecutionError = TransactionStrategyExecutionError; + fn try_map( + &self, + error: TransactionError, + signature: Option, + ) -> Result { + TransactionStrategyExecutionError::try_from_transaction_error( + error, signature, self.tasks, + ) + } +} + +impl From<&TransactionStrategyExecutionError> for ActionError { + fn from(value: &TransactionStrategyExecutionError) -> Self { + if let TransactionStrategyExecutionError::ActionsError(err, signature) = + value + { + Self::ActionsError(err.clone(), *signature) + } else { + Self::IntentFailedError(value.to_string()) + } + } +} diff --git a/magicblock-committor-service/src/intent_executor/strategy_executor/mod.rs b/magicblock-committor-service/src/intent_executor/strategy_executor/mod.rs new file mode 100644 index 000000000..46187a866 --- /dev/null +++ b/magicblock-committor-service/src/intent_executor/strategy_executor/mod.rs @@ -0,0 +1,5 @@ +pub mod error; +pub mod patcher; +pub mod single_stage; +pub mod two_stage; +pub mod utils; diff --git a/magicblock-committor-service/src/intent_executor/strategy_executor/patcher.rs b/magicblock-committor-service/src/intent_executor/strategy_executor/patcher.rs new file mode 100644 index 000000000..76e15c729 --- /dev/null +++ b/magicblock-committor-service/src/intent_executor/strategy_executor/patcher.rs @@ -0,0 +1,416 @@ +use std::ops::ControlFlow; + +use async_trait::async_trait; +use magicblock_core::traits::{ActionError, ActionsCallbackScheduler}; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signature::Signature; +use solana_signer::Signer; +use tracing::{error, warn}; + +use crate::{ + intent_executor::{ + error::{IntentExecutorError, IntentExecutorResult}, + intent_execution_client::IntentExecutionClient, + strategy_executor::{ + error::TransactionStrategyExecutionError, + utils::{ + handle_actions_result, handle_commit_id_error, + handle_undelegation_error, prepare_and_execute_strategy, + }, + }, + IntentExecutionReport, + }, + tasks::{ + task_info_fetcher::{CacheTaskInfoFetcher, TaskInfoFetcher}, + task_strategist::TransactionStrategy, + BaseTaskImpl, FinalizeTask, + }, + transaction_preparator::TransactionPreparator, +}; + +#[async_trait] +pub(in crate::intent_executor) trait Patcher { + async fn patch( + &mut self, + err: &TransactionStrategyExecutionError, + strategy: &mut TransactionStrategy, + report: &mut IntentExecutionReport, + ) -> IntentExecutorResult>; +} + +// --- SingleStagePatcher --- + +pub(in crate::intent_executor) struct SingleStagePatcher<'a, F, A, T> { + pub authority: &'a Keypair, + pub intent_client: &'a IntentExecutionClient, + pub callback_scheduler: &'a A, + pub task_info_fetcher: &'a CacheTaskInfoFetcher, + pub committed_pubkeys: &'a [Pubkey], + pub transaction_preparator: &'a T, +} + +impl SingleStagePatcher<'_, F, A, T> +where + T: TransactionPreparator, +{ + /// Handles unfinalized account error for single stage execution. + /// Sends a separate tx to finalize account and then continues execution. + async fn handle_unfinalized_account_error( + &self, + failed_signature: &Option, + delegated_account: Pubkey, + ) -> IntentExecutorResult> { + let finalize_task: BaseTaskImpl = + FinalizeTask { delegated_account }.into(); + prepare_and_execute_strategy( + self.intent_client, + self.authority, + self.transaction_preparator, + &mut TransactionStrategy { + optimized_tasks: vec![finalize_task], + lookup_tables_keys: vec![], + standalone_action_nonce: None, + }, + ) + .await + .map_err(IntentExecutorError::FailedFinalizePreparationError)? + .map_err(|err| IntentExecutorError::FailedToFinalizeError { + err, + commit_signature: None, + finalize_signature: *failed_signature, + })?; + + Ok(ControlFlow::Continue(TransactionStrategy { + optimized_tasks: vec![], + lookup_tables_keys: vec![], + standalone_action_nonce: None, + })) + } +} + +#[async_trait] +impl Patcher for SingleStagePatcher<'_, F, A, T> +where + F: TaskInfoFetcher, + A: ActionsCallbackScheduler, + T: TransactionPreparator, +{ + /// Patch the current `transaction_strategy` in response to a recoverable + /// [`TransactionStrategyExecutionError`], optionally preparing cleanup data + /// to be applied after a retry. + /// + /// [`TransactionStrategyExecutionError`], returning either: + /// - `Continue(to_cleanup)` when a retry should be attempted with cleanup metadata, or + /// - `Break(())` when this stage cannot be recovered here. + async fn patch( + &mut self, + err: &TransactionStrategyExecutionError, + strategy: &mut TransactionStrategy, + report: &mut IntentExecutionReport, + ) -> IntentExecutorResult> { + if self.committed_pubkeys.is_empty() { + // No patching is applicable if intent doesn't commit accounts + return Ok(ControlFlow::Break(())); + } + + match err { + TransactionStrategyExecutionError::ActionsError(err, signature) => { + // Here we patch strategy for it to be retried in next iteration + // & we also record data that has to be cleaned up after patch + let action_error = Err(ActionError::ActionsError(err.clone(), *signature)); + let to_cleanup = handle_actions_result( + &self.authority.pubkey(), + self.callback_scheduler, + report, + strategy, + *signature, + action_error, + ); + Ok(ControlFlow::Continue(to_cleanup)) + } + TransactionStrategyExecutionError::CommitIDError(_, _) => { + // Here we patch strategy for it to be retried in next iteration + // & we also record data that has to be cleaned up after patch + let to_cleanup = handle_commit_id_error( + &self.authority.pubkey(), + self.task_info_fetcher, + self.committed_pubkeys, + strategy, + ) + .await?; + Ok(ControlFlow::Continue(to_cleanup)) + } + err @ TransactionStrategyExecutionError::UnfinalizedAccountError( + _, + signature, + ) => { + let optimized_tasks = strategy.optimized_tasks.as_slice(); + if let Some(delegated_account) = err + .task_index() + .and_then(|index| optimized_tasks.get(index as usize)) + .and_then(|task| match task { + BaseTaskImpl::Commit(task) => { + Some(task.committed_account.pubkey) + } + BaseTaskImpl::CommitFinalize(task) => { + Some(task.committed_account.pubkey) + } + _ => None, + }) + { + self.handle_unfinalized_account_error(signature, delegated_account).await + } else { + error!( + task_index = err.task_index(), + optimized_tasks_len = optimized_tasks.len(), + error = ?err, + "RPC returned unexpected task index" + ); + Ok(ControlFlow::Break(())) + } + } + TransactionStrategyExecutionError::UndelegationError(_, _) => { + // Here we patch strategy for it to be retried in next iteration + // & we also record data that has to be cleaned up after patch + let to_cleanup = + handle_undelegation_error(&self.authority.pubkey(), strategy); + Ok(ControlFlow::Continue(to_cleanup)) + } + TransactionStrategyExecutionError::CpiLimitError(_, _) + | TransactionStrategyExecutionError::LoadedAccountsDataSizeExceeded( + _, + _, + ) + | TransactionStrategyExecutionError::TransactionTooLargeError(_) => { + // Can't be handled in scope of single stage execution + // We signal flow break + Ok(ControlFlow::Break(())) + } + TransactionStrategyExecutionError::InternalError(_) => { + // Error that we can't handle - break with cleanup data + Ok(ControlFlow::Break(())) + } + } + } +} + +// --- CommitStagePatcher --- + +pub(in crate::intent_executor) struct CommitStagePatcher<'a, F, A, T> { + pub authority: &'a Keypair, + pub intent_client: &'a IntentExecutionClient, + pub callback_scheduler: &'a A, + pub task_info_fetcher: &'a CacheTaskInfoFetcher, + pub committed_pubkeys: &'a [Pubkey], + pub transaction_preparator: &'a T, +} + +impl CommitStagePatcher<'_, F, A, T> +where + T: TransactionPreparator, +{ + /// Handles unfinalized account error for commit stage execution. + /// Sends a separate tx to finalize account and then continues execution. + async fn handle_unfinalized_account_error( + &self, + failed_signature: &Option, + delegated_account: Pubkey, + ) -> IntentExecutorResult> { + let finalize_task: BaseTaskImpl = + FinalizeTask { delegated_account }.into(); + prepare_and_execute_strategy( + self.intent_client, + self.authority, + self.transaction_preparator, + &mut TransactionStrategy { + optimized_tasks: vec![finalize_task], + lookup_tables_keys: vec![], + standalone_action_nonce: None, + }, + ) + .await + .map_err(IntentExecutorError::FailedCommitPreparationError)? + .map_err(|err| IntentExecutorError::FailedToCommitError { + err, + signature: *failed_signature, + })?; + + Ok(ControlFlow::Continue(TransactionStrategy { + optimized_tasks: vec![], + lookup_tables_keys: vec![], + standalone_action_nonce: None, + })) + } +} + +#[async_trait] +impl Patcher for CommitStagePatcher<'_, F, A, T> +where + F: TaskInfoFetcher, + A: ActionsCallbackScheduler, + T: TransactionPreparator, +{ + /// Patches Commit stage `transaction_strategy` in response to a recoverable + /// [`TransactionStrategyExecutionError`], optionally preparing cleanup data + /// to be applied after a retry. + /// + /// [`TransactionStrategyExecutionError`], returning either: + /// - `Continue(to_cleanup)` when a retry should be attempted with cleanup metadata, or + /// - `Break(())` when this stage cannot be recovered. + async fn patch( + &mut self, + err: &TransactionStrategyExecutionError, + strategy: &mut TransactionStrategy, + report: &mut IntentExecutionReport, + ) -> IntentExecutorResult> { + match err { + TransactionStrategyExecutionError::CommitIDError(_, _) => { + let to_cleanup = handle_commit_id_error( + &self.authority.pubkey(), + self.task_info_fetcher, + self.committed_pubkeys, + strategy, + ) + .await?; + Ok(ControlFlow::Continue(to_cleanup)) + } + err @ TransactionStrategyExecutionError::UnfinalizedAccountError( + _, + signature, + ) => { + let optimized_tasks = strategy.optimized_tasks.as_slice(); + let task_index = err.task_index(); + if let Some(delegated_account) = task_index + .and_then(|index| optimized_tasks.get(index as usize)) + .and_then(|task| match task { + BaseTaskImpl::Commit(task) => { + Some(task.committed_account.pubkey) + } + BaseTaskImpl::CommitFinalize(task) => { + Some(task.committed_account.pubkey) + } + _ => None, + }) + { + self.handle_unfinalized_account_error(signature, delegated_account).await + } else { + error!( + task_index = ?task_index, + optimized_tasks_len = optimized_tasks.len(), + error = ?err, + "RPC returned unexpected task index" + ); + Ok(ControlFlow::Break(())) + } + } + TransactionStrategyExecutionError::ActionsError(err, signature) => { + // Intent bundles allow for actions to be in commit stage + let action_error = Err(ActionError::ActionsError(err.clone(), *signature)); + let to_cleanup = handle_actions_result( + &self.authority.pubkey(), + self.callback_scheduler, + report, + strategy, + *signature, + action_error, + ); + Ok(ControlFlow::Continue(to_cleanup)) + } + TransactionStrategyExecutionError::UndelegationError(_, _) => { + // Unexpected in Two Stage commit + error!(error = ?err, "Unexpected error in two stage commit flow"); + Ok(ControlFlow::Break(())) + } + TransactionStrategyExecutionError::CpiLimitError(_, _) + | TransactionStrategyExecutionError::LoadedAccountsDataSizeExceeded( + _, + _, + ) => { + // Can't be handled + error!(error = ?err, "Commit tasks exceeded execution limit"); + Ok(ControlFlow::Break(())) + } + TransactionStrategyExecutionError::TransactionTooLargeError(err) => { + error!(error = ?err, "Failed to execute intent: transaction too large!"); + Ok(ControlFlow::Break(())) + } + TransactionStrategyExecutionError::InternalError(_) => { + // Can't be handled + Ok(ControlFlow::Break(())) + } + } + } +} + +pub(in crate::intent_executor) struct FinalizeStagePatcher<'a, A> { + pub authority: &'a Keypair, + pub callback_scheduler: &'a A, +} + +#[async_trait] +impl Patcher for FinalizeStagePatcher<'_, A> +where + A: ActionsCallbackScheduler, +{ + /// Patches Finalize stage `transaction_strategy` in response to a recoverable + /// [`TransactionStrategyExecutionError`], optionally preparing cleanup data + /// to be applied after a retry. + /// + /// [`TransactionStrategyExecutionError`], returning either: + /// - `Continue(to_cleanup)` when a retry should be attempted with cleanup metadata, or + /// - `Break(())` when this stage cannot be recovered. + async fn patch( + &mut self, + err: &TransactionStrategyExecutionError, + strategy: &mut TransactionStrategy, + report: &mut IntentExecutionReport, + ) -> IntentExecutorResult> { + match err { + TransactionStrategyExecutionError::CommitIDError(_, _) + | TransactionStrategyExecutionError::UnfinalizedAccountError(_, _) => { + // Unexpected error in Two Stage finalize + error!(error = ?err, "Unexpected error in two stage finalize flow"); + Ok(ControlFlow::Break(())) + } + TransactionStrategyExecutionError::ActionsError(err, signature) => { + // Here we patch strategy for it to be retried in next iteration + // & we also record data that has to be cleaned up after patch + let action_error = Err(ActionError::ActionsError(err.clone(), *signature)); + let to_cleanup = handle_actions_result( + &self.authority.pubkey(), + self.callback_scheduler, + report, + strategy, + *signature, + action_error, + ); + Ok(ControlFlow::Continue(to_cleanup)) + } + TransactionStrategyExecutionError::UndelegationError(_, _) => { + // Here we patch strategy for it to be retried in next iteration + // & we also record data that has to be cleaned up after patch + let to_cleanup = + handle_undelegation_error(&self.authority.pubkey(), strategy); + Ok(ControlFlow::Continue(to_cleanup)) + } + TransactionStrategyExecutionError::CpiLimitError(_, _) + | TransactionStrategyExecutionError::LoadedAccountsDataSizeExceeded( + _, + _, + ) => { + // Can't be handled + warn!(error = ?err, "Finalization tasks exceeded execution limit"); + Ok(ControlFlow::Break(())) + } + TransactionStrategyExecutionError::TransactionTooLargeError(err) => { + error!(error = ?err, "Failed to execute intent: transaction too large!"); + Ok(ControlFlow::Break(())) + } + TransactionStrategyExecutionError::InternalError(_) => { + // Can't be handled + Ok(ControlFlow::Break(())) + } + } + } +} diff --git a/magicblock-committor-service/src/intent_executor/strategy_executor/single_stage.rs b/magicblock-committor-service/src/intent_executor/strategy_executor/single_stage.rs new file mode 100644 index 000000000..eabf603c8 --- /dev/null +++ b/magicblock-committor-service/src/intent_executor/strategy_executor/single_stage.rs @@ -0,0 +1,148 @@ +use std::sync::Arc; + +use magicblock_core::traits::{ActionError, ActionsCallbackScheduler}; +use magicblock_program::outbox::{ExecutionStage, PendingTransaction}; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signature::Signature; +use solana_signer::Signer; +use tracing::instrument; + +use crate::{ + intent_executor::{ + error::{IntentExecutorError, IntentExecutorResult}, + intent_execution_client::IntentExecutionClient, + strategy_executor::{ + patcher::SingleStagePatcher, + utils::{ + handle_actions_result, stage_execution_loop, ExecutionState, + }, + }, + IntentExecutionReport, + }, + outbox::OutboxClient, + tasks::{ + task_info_fetcher::{CacheTaskInfoFetcher, TaskInfoFetcher}, + task_strategist::TransactionStrategy, + }, + transaction_preparator::TransactionPreparator, +}; + +pub struct SingleStageStrategyExecutor<'a, F, A, O> { + current_attempt: u8, + pending_transaction: Option, + execution_report: &'a mut IntentExecutionReport, + + authority: Keypair, + intent_id: u64, + intent_client: IntentExecutionClient, + task_info_fetcher: Arc>, + outbox_client: Arc, + callback_scheduler: A, + transaction_strategy: TransactionStrategy, +} + +impl<'a, F, A, O> SingleStageStrategyExecutor<'a, F, A, O> +where + F: TaskInfoFetcher, + A: ActionsCallbackScheduler, + O: OutboxClient, + O::Error: Into, +{ + #[allow(clippy::too_many_arguments)] + pub fn new( + authority: Keypair, + intent_id: u64, + intent_client: IntentExecutionClient, + task_info_fetcher: Arc>, + outbox_client: Arc, + transaction_strategy: TransactionStrategy, + callback_scheduler: A, + execution_report: &'a mut IntentExecutionReport, + ) -> Self { + Self { + authority, + intent_id, + pending_transaction: None, + intent_client, + task_info_fetcher, + outbox_client, + transaction_strategy, + callback_scheduler, + execution_report, + current_attempt: 0, + } + } + + #[instrument( + skip(self, committed_pubkeys, transaction_preparator), + fields(stage = "single_stage") + )] + pub async fn execute( + &mut self, + committed_pubkeys: &[Pubkey], + transaction_preparator: &T, + ) -> IntentExecutorResult + where + T: TransactionPreparator, + { + let patcher = SingleStagePatcher { + authority: &self.authority, + intent_client: &self.intent_client, + callback_scheduler: &self.callback_scheduler, + task_info_fetcher: &self.task_info_fetcher, + committed_pubkeys, + transaction_preparator, + }; + let execution_state = ExecutionState { + current_attempt: &mut self.current_attempt, + transaction_strategy: &mut self.transaction_strategy, + pending_transaction: &mut self.pending_transaction, + execution_report: self.execution_report, + }; + let result = stage_execution_loop( + &self.authority, + &self.intent_client, + &*self.outbox_client, + transaction_preparator, + patcher, + self.intent_id, + ExecutionStage::SingleStage, + IntentExecutorError::FailedFinalizePreparationError, + execution_state, + ) + .await?; + + result.map_err(|err| { + IntentExecutorError::from_finalize_execution_error( + err, + // TODO(edwin): shall one stage have same signature for commit & finalize + None, + ) + }) + } + + pub fn has_callbacks(&self) -> bool { + self.transaction_strategy.has_actions_callbacks() + } + + pub fn execute_callbacks( + &mut self, + signature: Option, + result: Result<(), impl Into>, + ) { + let junk_strategy = handle_actions_result( + &self.authority.pubkey(), + &self.callback_scheduler, + self.execution_report, + &mut self.transaction_strategy, + signature, + result.map_err(|err| err.into()), + ); + self.execution_report.dispose(junk_strategy); + } + + pub fn consume_strategy(self) -> TransactionStrategy { + self.transaction_strategy + } +} diff --git a/magicblock-committor-service/src/intent_executor/strategy_executor/two_stage.rs b/magicblock-committor-service/src/intent_executor/strategy_executor/two_stage.rs new file mode 100644 index 000000000..af1a6f284 --- /dev/null +++ b/magicblock-committor-service/src/intent_executor/strategy_executor/two_stage.rs @@ -0,0 +1,387 @@ +use std::{mem, sync::Arc}; + +use magicblock_core::traits::{ActionError, ActionsCallbackScheduler}; +use magicblock_program::outbox::{ + ExecutionStage, PendingTransaction, TwoStageProgress, +}; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_signature::Signature; +use solana_signer::Signer; +use tracing::instrument; + +use crate::{ + intent_executor::{ + error::{IntentExecutorError, IntentExecutorResult}, + intent_execution_client::IntentExecutionClient, + strategy_executor::{ + patcher::{CommitStagePatcher, FinalizeStagePatcher}, + two_stage::sealed::Sealed, + utils::{ + handle_actions_result, stage_execution_loop, ExecutionState, + }, + }, + IntentExecutionReport, + }, + outbox::OutboxClient, + tasks::{ + task_info_fetcher::{CacheTaskInfoFetcher, TaskInfoFetcher}, + task_strategist::TransactionStrategy, + }, + transaction_preparator::TransactionPreparator, +}; + +pub struct Initialized { + /// Commit stage strategy + commit_strategy: TransactionStrategy, + /// Finalize stage strategy + finalize_strategy: TransactionStrategy, + /// Transaction pending confirmation + /// Sources: Outbox with status Committing, timeout + pending_transaction: Option, + current_attempt: u8, +} + +impl Initialized { + pub fn new( + commit_strategy: TransactionStrategy, + finalize_strategy: TransactionStrategy, + ) -> Self { + Self { + commit_strategy, + finalize_strategy, + pending_transaction: None, + current_attempt: 0, + } + } +} + +pub struct Committed { + /// Signature of commit stage + commit_signature: Signature, + /// Finalize stage strategy + finalize_strategy: TransactionStrategy, + /// Signature pending confirmation + /// Sources: Outbox with status Finalizing, timeout + pending_transaction: Option, + current_attempt: u8, +} + +impl Committed { + pub fn new( + commit_signature: Signature, + finalize_strategy: TransactionStrategy, + ) -> Self { + Self { + commit_signature, + finalize_strategy, + pending_transaction: None, + current_attempt: 0, + } + } +} + +pub struct Finalized { + /// Signature of commit stage + pub commit_signature: Signature, + /// Signature of finalize stage + pub finalize_signature: Signature, +} + +pub struct TwoStageStrategyExecutor<'a, A, O, S: Sealed> { + /// State per stage + state: S, + /// Common state across the stages + authority: Keypair, + intent_id: u64, + intent_client: IntentExecutionClient, + outbox_client: Arc, + callback_scheduler: A, + execution_report: &'a mut IntentExecutionReport, +} + +impl<'a, A, O> TwoStageStrategyExecutor<'a, A, O, Initialized> +where + A: ActionsCallbackScheduler, + O: OutboxClient, + O::Error: Into, +{ + pub fn new( + state: Initialized, + authority: Keypair, + intent_id: u64, + intent_client: IntentExecutionClient, + outbox_client: Arc, + callback_scheduler: A, + execution_report: &'a mut IntentExecutionReport, + ) -> Self { + Self { + authority, + intent_id, + intent_client, + execution_report, + callback_scheduler, + outbox_client, + state, + } + } + + #[instrument( + skip( + self, + committed_pubkeys, + transaction_preparator, + task_info_fetcher, + ), + fields(stage = "commit") + )] + pub async fn commit( + &mut self, + committed_pubkeys: &[Pubkey], + transaction_preparator: &T, + task_info_fetcher: &CacheTaskInfoFetcher, + ) -> IntentExecutorResult + where + T: TransactionPreparator, + F: TaskInfoFetcher, + { + let execution_state = ExecutionState { + current_attempt: &mut self.state.current_attempt, + transaction_strategy: &mut self.state.commit_strategy, + pending_transaction: &mut self.state.pending_transaction, + execution_report: self.execution_report, + }; + let commit_stage_patcher = CommitStagePatcher { + authority: &self.authority, + intent_client: &self.intent_client, + callback_scheduler: &self.callback_scheduler, + task_info_fetcher, + committed_pubkeys, + transaction_preparator, + }; + + // Execute commit stage + let commit_result = stage_execution_loop( + &self.authority, + &self.intent_client, + &*self.outbox_client, + transaction_preparator, + commit_stage_patcher, + self.intent_id, + |pending_tx| { + ExecutionStage::TwoStage(TwoStageProgress::Committing( + pending_tx, + )) + }, + IntentExecutorError::FailedCommitPreparationError, + execution_state, + ) + .await?; + self.execute_callbacks( + commit_result.as_ref().ok().copied(), + commit_result.as_ref().map(|_| ()), + ); + self.execution_report + .dispose(self.state.commit_strategy.clone()); + if commit_result.is_err() { + self.execution_report + .dispose(mem::take(&mut self.state.finalize_strategy)); + } + commit_result.map_err(|err| { + IntentExecutorError::from_commit_execution_error(err) + }) + } + + pub fn has_callbacks(&self) -> bool { + self.state.commit_strategy.has_actions_callbacks() + || self.state.finalize_strategy.has_actions_callbacks() + } + + /// On `Err`: removes actions from both commit and finalize strategies and + /// executes all their callbacks with the error. + /// On `Ok`: removes actions only from commit strategy and executes their + /// callbacks, preserving finalize-stage actions for the finalize phase. + pub fn execute_callbacks( + &mut self, + signature: Option, + result: Result<(), impl Into>, + ) { + let result = result.map(|_| ()).map_err(|err| err.into()); + let junk_strategy = handle_actions_result( + &self.authority.pubkey(), + &self.callback_scheduler, + self.execution_report, + &mut self.state.commit_strategy, + signature, + result.clone(), + ); + self.execution_report.dispose(junk_strategy); + + if result.is_err() { + let junk_strategy = handle_actions_result( + &self.authority.pubkey(), + &self.callback_scheduler, + self.execution_report, + &mut self.state.finalize_strategy, + signature, + result, + ); + self.execution_report.dispose(junk_strategy); + } + } + + /// Transitions to next executor state + pub fn done( + #[allow(unused_mut)] mut self, + commit_signature: Signature, + ) -> TwoStageStrategyExecutor<'a, A, O, Committed> { + #[cfg(feature = "dev-context-only-utils")] + self.execution_report + .add_succeeded_transaction_strategy(mem::take( + &mut self.state.commit_strategy, + )); + + TwoStageStrategyExecutor { + authority: self.authority, + intent_id: self.intent_id, + intent_client: self.intent_client, + outbox_client: self.outbox_client, + callback_scheduler: self.callback_scheduler, + execution_report: self.execution_report, + state: Committed { + commit_signature, + finalize_strategy: self.state.finalize_strategy, + pending_transaction: None, + current_attempt: 0, + }, + } + } +} + +impl<'a, A, O> TwoStageStrategyExecutor<'a, A, O, Committed> +where + A: ActionsCallbackScheduler, + O: OutboxClient, + O::Error: Into, +{ + pub fn committed( + state: Committed, + authority: Keypair, + intent_id: u64, + intent_client: IntentExecutionClient, + outbox_client: Arc, + callback_scheduler: A, + execution_report: &'a mut IntentExecutionReport, + ) -> Self { + Self { + authority, + intent_id, + intent_client, + execution_report, + callback_scheduler, + outbox_client, + state, + } + } + + #[instrument( + skip(self, transaction_preparator), + fields(stage = "finalize") + )] + pub async fn finalize( + &mut self, + transaction_preparator: &T, + ) -> IntentExecutorResult + where + T: TransactionPreparator, + { + let commit_signature = self.state.commit_signature; + let execution_state = ExecutionState { + current_attempt: &mut self.state.current_attempt, + transaction_strategy: &mut self.state.finalize_strategy, + pending_transaction: &mut self.state.pending_transaction, + execution_report: self.execution_report, + }; + let finalize_stage_patcher = FinalizeStagePatcher { + authority: &self.authority, + callback_scheduler: &self.callback_scheduler, + }; + + // Execute finalize stage + let finalize_result = stage_execution_loop( + &self.authority, + &self.intent_client, + &*self.outbox_client, + transaction_preparator, + finalize_stage_patcher, + self.intent_id, + |pending_tx| { + ExecutionStage::TwoStage(TwoStageProgress::Finalizing { + commit: commit_signature, + finalize: pending_tx, + }) + }, + IntentExecutorError::FailedFinalizePreparationError, + execution_state, + ) + .await?; + // Even if failed - dump finalize into junk + self.execute_callbacks( + finalize_result.as_ref().ok().copied(), + finalize_result.as_ref().map(|_| ()), + ); + self.execution_report + .dispose(self.state.finalize_strategy.clone()); + finalize_result.map_err(|err| { + IntentExecutorError::from_finalize_execution_error( + err, + Some(self.state.commit_signature), + ) + }) + } + + pub fn has_callbacks(&self) -> bool { + self.state.finalize_strategy.has_actions_callbacks() + } + + /// Removes actions from finalize strategy + /// Executes callbacks + pub fn execute_callbacks( + &mut self, + signature: Option, + result: Result<(), impl Into>, + ) { + let junk_strategy = handle_actions_result( + &self.authority.pubkey(), + &self.callback_scheduler, + self.execution_report, + &mut self.state.finalize_strategy, + signature, + result.map_err(|err| err.into()), + ); + self.execution_report.dispose(junk_strategy); + } + + /// Transitions to next executor state + #[allow(unused_mut)] + pub fn done(mut self, finalize_signature: Signature) -> Finalized { + #[cfg(feature = "dev-context-only-utils")] + self.execution_report + .add_succeeded_transaction_strategy(mem::take( + &mut self.state.finalize_strategy, + )); + + Finalized { + commit_signature: self.state.commit_signature, + finalize_signature, + } + } +} + +mod sealed { + pub trait Sealed {} + + impl Sealed for super::Initialized {} + impl Sealed for super::Committed {} + impl Sealed for super::Finalized {} +} diff --git a/magicblock-committor-service/src/intent_executor/strategy_executor/utils.rs b/magicblock-committor-service/src/intent_executor/strategy_executor/utils.rs new file mode 100644 index 000000000..eb9dcffc9 --- /dev/null +++ b/magicblock-committor-service/src/intent_executor/strategy_executor/utils.rs @@ -0,0 +1,646 @@ +use std::{ops::ControlFlow, time::Duration}; + +use async_trait::async_trait; +use magicblock_core::traits::{ + ActionError, ActionResult, ActionsCallbackScheduler, +}; +use magicblock_program::outbox::{ExecutionStage, PendingTransaction}; +use solana_keypair::Keypair; +use solana_pubkey::Pubkey; +use solana_rpc_client::rpc_client::SerializableTransaction; +use solana_signature::Signature; +use solana_transaction::versioned::VersionedTransaction; +use tokio::time::{sleep, timeout}; +use tracing::{error, info}; + +use crate::{ + intent_executor::{ + error::{IntentExecutorError, IntentExecutorResult}, + intent_execution_client::IntentExecutionClient, + strategy_executor::{ + error::TransactionStrategyExecutionError, + patcher::Patcher, + single_stage::SingleStageStrategyExecutor, + two_stage::{Committed, Initialized, TwoStageStrategyExecutor}, + }, + IntentExecutionReport, + }, + outbox::OutboxClient, + tasks::{ + task_builder::TaskBuilderError, + task_info_fetcher::{CacheTaskInfoFetcher, ResetType, TaskInfoFetcher}, + task_strategist::{TaskStrategist, TransactionStrategy}, + BaseTaskImpl, + }, + transaction_preparator::{ + error::TransactionPreparatorError, TransactionPreparator, + }, +}; + +const STAGE_LOOP_CEILING: u8 = 10; + +/// How long to wait between re-checks of a pending signature that is still +/// within its blockhash's valid window (i.e. it might still land). +const PENDING_SIGNATURE_POLL_INTERVAL: Duration = Duration::from_secs(2); + +pub(in crate::intent_executor) struct ExecutionState<'a> { + pub current_attempt: &'a mut u8, + pub transaction_strategy: &'a mut TransactionStrategy, + pub pending_transaction: &'a mut Option, + pub execution_report: &'a mut IntentExecutionReport, +} + +#[allow(clippy::too_many_arguments)] +pub(in crate::intent_executor) async fn stage_execution_loop<'a, T, O, P>( + authority: &Keypair, + intent_client: &IntentExecutionClient, + outbox_client: &O, + transaction_preparator: &T, + mut patcher: P, + intent_id: u64, + make_outbox_stage: impl Fn(PendingTransaction) -> ExecutionStage, + map_preparation_err: fn(TransactionPreparatorError) -> IntentExecutorError, + state: ExecutionState<'a>, +) -> IntentExecutorResult> +where + T: TransactionPreparator, + O: OutboxClient, + O::Error: Into, + P: Patcher, +{ + loop { + if let Some(ref pending) = state.pending_transaction { + match check_pending_signature(intent_client, pending).await? { + PendingSignatureStatus::Succeeded => { + break Ok(Ok(pending.signature)); + } + // May still land - don't resend, just re-check later + PendingSignatureStatus::Pending => { + sleep(PENDING_SIGNATURE_POLL_INTERVAL).await; + continue; + } + PendingSignatureStatus::Rejected => { + *state.pending_transaction = None; + } + } + } + + *state.current_attempt += 1; + + // Prepare message + let prepared_transaction = prepare_transaction( + intent_client, + authority, + transaction_preparator, + state.transaction_strategy, + ) + .await + .map_err(map_preparation_err)?; + + // Record in outbox before sending + let pending = PendingTransaction { + signature: *prepared_transaction.get_signature(), + blockhash: *prepared_transaction.get_recent_blockhash(), + }; + outbox_client + .set_intent_execution_stage(intent_id, make_outbox_stage(pending)) + .await + .map_err(Into::into)?; + + // Now record locally signature of tx we about to send + // Precaution for timeout in between + *state.pending_transaction = Some(pending); + let signature = &pending.signature; + + // Send signed tx + let execution_result = intent_client + .send_signed_tx_with_retries( + &prepared_transaction, + &state.transaction_strategy.optimized_tasks, + ) + .await; + + // Result returned, cleanup pending signature + *state.pending_transaction = None; + + let execution_err = match execution_result { + Ok(()) => return Ok(Ok(*signature)), + Err(err) => err, + }; + let flow = patcher + .patch( + &execution_err, + state.transaction_strategy, + state.execution_report, + ) + .await?; + let cleanup = match flow { + ControlFlow::Continue(value) => value, + ControlFlow::Break(()) => return Ok(Err(execution_err)), + }; + intent_client.invalidate_cached_blockhash().await; + state.execution_report.dispose(cleanup); + + if *state.current_attempt >= STAGE_LOOP_CEILING { + error!("CRITICAL! Recursion ceiling reached"); + return Ok(Err(execution_err)); + } else { + state.execution_report.add_patched_error(execution_err); + } + } +} + +pub async fn prepare_transaction( + client: &IntentExecutionClient, + authority: &Keypair, + transaction_preparator: &T, + transaction_strategy: &mut TransactionStrategy, +) -> Result { + let mut prepared_message = transaction_preparator + .prepare_for_strategy(authority, transaction_strategy) + .await?; + + // Get latest blockhash(Part of preparation I guess) + let blockhash = client + .get_latest_blockhash() + .await + .map_err(TransactionPreparatorError::GetLatestBlockhashError)?; + prepared_message.set_recent_blockhash(blockhash); + + // Create and sign transaction(Part of transaction preparation i guess, hence the errors) + let transaction = + VersionedTransaction::try_new(prepared_message, &[&authority]) + .map_err(TransactionPreparatorError::SignerError)?; + Ok(transaction) +} + +pub(in crate::intent_executor) enum PendingSignatureStatus { + /// The transaction landed successfully. + Succeeded, + /// Not found on-chain yet, but its blockhash is still within its valid + /// window - it may still land. Callers must not resend. + Pending, + /// The transaction landed and failed, or its blockhash has expired + /// without landing - guaranteed dead, safe to send a new transaction. + Rejected, +} + +/// Checks a pending signature loaded from the outbox against the full +/// transaction history. A signature that isn't found on-chain is ambiguous +/// on its own — Solana's RPC can't tell "never sent" apart from "sent but +/// not yet landed" — so we additionally check whether its blockhash is +/// still valid to decide between `Pending` (may still land) and `Rejected` +/// (guaranteed dead). +/// +/// Blockhash validity is checked *before* signature status, not after. +/// Validity is monotonic (once expired, it stays expired), so a +/// slightly-stale "expired" reading is still safe to act on later. But +/// "signature not found" is a snapshot that can flip to "found" at any +/// later instant — if we checked it first and the blockhash-expired check +/// second, the tx could land in the gap between the two calls and we'd +/// wrongly conclude `Rejected`, causing a double execution. +pub(in crate::intent_executor) async fn check_pending_signature( + client: &IntentExecutionClient, + pending: &PendingTransaction, +) -> IntentExecutorResult { + let blockhash_still_valid = client + .is_blockhash_valid(&pending.blockhash) + .await + .map_err(IntentExecutorError::GetPendingSignatureStatusError)?; + + let statuses = client + .get_signature_statuses_with_history(std::slice::from_ref( + &pending.signature, + )) + .await + .map_err(IntentExecutorError::GetPendingSignatureStatusError)?; + + match statuses.first() { + Some(Some(Ok(()))) => Ok(PendingSignatureStatus::Succeeded), + Some(Some(Err(err))) => { + info!(pending_signature = ?pending.signature, error = ?err, "Transaction corresponding to pending signature failed"); + Ok(PendingSignatureStatus::Rejected) + } + // Not found on-chain: could be genuinely never sent, or simply not + // landed yet. + None | Some(None) => { + if blockhash_still_valid { + Ok(PendingSignatureStatus::Pending) + } else { + info!(pending_signature = ?pending.signature, "Pending transaction's blockhash expired without landing"); + Ok(PendingSignatureStatus::Rejected) + } + } + } +} + +/// Waits until a pending signature resolves definitively, sleeping and +/// re-checking while `Pending` (naturally bounded by the blockhash's own +/// expiry window). Returns `true` if the transaction landed successfully, +/// `false` if it's guaranteed dead (failed on-chain or blockhash expired). +/// Callers that don't already sit inside a retry loop (e.g. resuming after +/// a restart) should use this instead of calling `check_pending_signature` +/// directly, to avoid acting on a `Pending` result as if it were safe to +/// resend. +pub(in crate::intent_executor) async fn resolve_pending_signature( + client: &IntentExecutionClient, + pending: &PendingTransaction, +) -> IntentExecutorResult { + loop { + match check_pending_signature(client, pending).await? { + PendingSignatureStatus::Succeeded => return Ok(true), + PendingSignatureStatus::Rejected => return Ok(false), + PendingSignatureStatus::Pending => { + sleep(PENDING_SIGNATURE_POLL_INTERVAL).await; + } + } + } +} + +pub async fn prepare_and_execute_strategy( + client: &IntentExecutionClient, + authority: &Keypair, + transaction_preparator: &T, + transaction_strategy: &mut TransactionStrategy, +) -> Result< + Result, + TransactionPreparatorError, +> +where + T: TransactionPreparator, +{ + let prepared_message = transaction_preparator + .prepare_for_strategy(authority, transaction_strategy) + .await?; + + let execution_result = client + .execute_message_with_retries( + authority, + prepared_message, + &transaction_strategy.optimized_tasks, + ) + .await; + + Ok(execution_result) +} + +/// Handles out of sync commit id error, fixes current strategy +/// Returns strategy to be cleaned up +/// TODO(edwin): TransactionStrategy -> CleanupStrategy or something, naming is confusing for something that is cleaned up +pub(in crate::intent_executor) async fn handle_commit_id_error< + T: TaskInfoFetcher, +>( + authority: &Pubkey, + task_info_fetcher: &CacheTaskInfoFetcher, + committed_pubkeys: &[Pubkey], + strategy: &mut TransactionStrategy, +) -> Result { + let min_context_slot = strategy + .optimized_tasks + .iter() + .filter_map(|task| match task { + BaseTaskImpl::Commit(task) => { + Some(task.committed_account.remote_slot) + } + BaseTaskImpl::CommitFinalize(task) => { + Some(task.committed_account.remote_slot) + } + _ => None, + }) + .max() + .unwrap_or_default(); + + // We reset TaskInfoFetcher for all committed accounts + // We re-fetch them to fix out of sync tasks + task_info_fetcher.reset(ResetType::Specific(committed_pubkeys)); + let commit_ids = task_info_fetcher + .fetch_next_commit_nonces(committed_pubkeys, min_context_slot) + .await + .map_err(TaskBuilderError::CommitTasksBuildError)?; + + // Here we find the broken tasks and reset them + // Broken tasks are prepared incorrectly so they have to be cleaned up + let mut to_cleanup = Vec::new(); + for task in &mut strategy.optimized_tasks { + match task { + BaseTaskImpl::Commit(task) => { + let Some(commit_id) = + commit_ids.get(&task.committed_account.pubkey) + else { + continue; + }; + if commit_id == &task.commit_id { + continue; + } + + // Handle invalid tasks + to_cleanup.push(BaseTaskImpl::Commit(task.clone())); + task.reset_commit_id(*commit_id); + } + BaseTaskImpl::CommitFinalize(task) => { + let Some(commit_id) = + commit_ids.get(&task.committed_account.pubkey) + else { + continue; + }; + if commit_id == &task.commit_id { + continue; + } + + // Handle invalid tasks + to_cleanup.push(BaseTaskImpl::CommitFinalize(task.clone())); + task.reset_commit_id(*commit_id); + } + _ => {} + } + } + + let old_alts = strategy.dummy_revaluate_alts(authority); + Ok(TransactionStrategy { + optimized_tasks: to_cleanup, + lookup_tables_keys: old_alts, + standalone_action_nonce: None, + }) +} + +/// Handle CPI limit error, splits single strategy flow into 2 +/// Returns Commit stage strategy, Finalize stage strategy and strategy to clean up +pub(in crate::intent_executor) fn handle_cpi_limit_error( + authority: &Pubkey, + strategy: TransactionStrategy, +) -> ( + TransactionStrategy, + TransactionStrategy, + TransactionStrategy, +) { + // We encountered error "Max instruction trace length exceeded" + // All the tasks a prepared to be executed at this point + // We attempt Two stages commit flow, need to split tasks up + let last_commit_ind = strategy.optimized_tasks.iter().rposition(|el| { + matches!( + el, + BaseTaskImpl::Commit(_) | BaseTaskImpl::CommitFinalize(_) + ) + }); + let (mut commit_stage_tasks, mut finalize_stage_tasks) = (vec![], vec![]); + for (i, el) in strategy.optimized_tasks.into_iter().enumerate() { + if Some(i) <= last_commit_ind { + commit_stage_tasks.push(el); + } else { + finalize_stage_tasks.push(el); + } + } + + let commit_alt_pubkeys = if strategy.lookup_tables_keys.is_empty() { + vec![] + } else { + TaskStrategist::collect_lookup_table_keys( + authority, + &commit_stage_tasks, + ) + }; + let commit_strategy = TransactionStrategy { + optimized_tasks: commit_stage_tasks, + lookup_tables_keys: commit_alt_pubkeys, + standalone_action_nonce: None, + }; + + let finalize_alt_pubkeys = if strategy.lookup_tables_keys.is_empty() { + vec![] + } else { + TaskStrategist::collect_lookup_table_keys( + authority, + &finalize_stage_tasks, + ) + }; + let finalize_strategy = TransactionStrategy { + optimized_tasks: finalize_stage_tasks, + lookup_tables_keys: finalize_alt_pubkeys, + standalone_action_nonce: None, + }; + + // We clean up only ALTs + let to_cleanup = TransactionStrategy { + optimized_tasks: vec![], + lookup_tables_keys: strategy.lookup_tables_keys, + standalone_action_nonce: None, + }; + + (commit_strategy, finalize_strategy, to_cleanup) +} + +/// Handles undelegation error, stripping away actions +/// Returns [`TransactionStrategy`] to be cleaned up +pub(in crate::intent_executor) fn handle_undelegation_error( + authority: &Pubkey, + strategy: &mut TransactionStrategy, +) -> TransactionStrategy { + let position = strategy + .optimized_tasks + .iter() + .position(|el| matches!(el, BaseTaskImpl::Undelegate(_))); + + if let Some(position) = position { + // Remove everything after undelegation including post undelegation actions + let removed_task = strategy.optimized_tasks.drain(position..).collect(); + let old_alts = strategy.dummy_revaluate_alts(authority); + TransactionStrategy { + optimized_tasks: removed_task, + lookup_tables_keys: old_alts, + standalone_action_nonce: None, + } + } else { + TransactionStrategy { + optimized_tasks: vec![], + lookup_tables_keys: vec![], + standalone_action_nonce: None, + } + } +} + +pub(in crate::intent_executor) fn handle_actions_result( + authority: &Pubkey, + callback_scheduler: &A, + execution_report: &mut IntentExecutionReport, + transaction_strategy: &mut TransactionStrategy, + signature: Option, + result: ActionResult, +) -> TransactionStrategy +where + A: ActionsCallbackScheduler, +{ + let (callbacks, junk) = match result { + // TODO(edwin): add in PR description + // Timeout may be triggered right after tx was sent + // It could also be triggered prior + // In order to give simple contract to user we keep actions in both cases + Ok(()) | Err(ActionError::TimeoutError) => { + let callbacks = transaction_strategy.extract_action_callbacks(); + (callbacks, TransactionStrategy::default()) + } + _ => { + let mut removed_actions = + transaction_strategy.remove_actions(authority); + let callbacks = removed_actions.extract_action_callbacks(); + (callbacks, removed_actions) + } + }; + if !callbacks.is_empty() { + let result = callback_scheduler.schedule(callbacks, signature, result); + execution_report.add_callback_report(result); + } + + junk +} + +/// Executes a stage with an optional timeout that applies only to callbacks. +/// If the timeout fires, callbacks are triggered with `TimeoutError` immediately +/// (respecting the timeout contract on the user smart contract side), then +/// execution continues without a timeout to drive the intent to completion. +pub(in crate::intent_executor) async fn execute_with_timeout( + time_left: Option, + mut executor: impl StageExecutor, +) -> IntentExecutorResult { + if executor.has_callbacks() { + if let Some(time_left) = time_left { + match timeout(time_left, executor.execute()).await { + Ok(res) => return res, + Err(_) => { + // The race between callback and intent txn is handled + // on the user smart contract side via TimeoutError. + // We must respect the timeout contract. + info!("Intent execution timed out, cleaning up actions"); + executor.execute_callbacks( + None, + Err(ActionError::TimeoutError), + ); + } + } + } else { + // Already timed out; see comment above. + executor.execute_callbacks(None, Err(ActionError::TimeoutError)); + } + } + + // Timeout concerns only callbacks + // We still need to drive intent to completion + executor.execute().await +} + +#[async_trait] +pub(in crate::intent_executor) trait StageExecutor { + fn has_callbacks(&self) -> bool; + async fn execute(&mut self) -> IntentExecutorResult; + fn execute_callbacks( + &mut self, + signature: Option, + result: ActionResult, + ); +} + +pub(in crate::intent_executor) struct SingleStage<'a, 'e, A, T, F, O> { + pub(in crate::intent_executor) inner: + &'a mut SingleStageStrategyExecutor<'e, F, A, O>, + pub(in crate::intent_executor) transaction_preparator: &'a T, + pub(in crate::intent_executor) committed_pubkeys: &'a [Pubkey], +} + +#[async_trait] +impl<'a, 'e, A, T, F, O> StageExecutor for SingleStage<'a, 'e, A, T, F, O> +where + A: ActionsCallbackScheduler, + T: TransactionPreparator, + F: TaskInfoFetcher, + O: OutboxClient, + O::Error: Into, +{ + fn has_callbacks(&self) -> bool { + self.inner.has_callbacks() + } + + async fn execute(&mut self) -> IntentExecutorResult { + self.inner + .execute(self.committed_pubkeys, self.transaction_preparator) + .await + } + + fn execute_callbacks( + &mut self, + signature: Option, + result: ActionResult, + ) { + self.inner.execute_callbacks(signature, result) + } +} + +pub(in crate::intent_executor) struct CommitStage<'a, 'e, A, T, F, O> { + pub(in crate::intent_executor) inner: + &'a mut TwoStageStrategyExecutor<'e, A, O, Initialized>, + pub(in crate::intent_executor) transaction_preparator: &'a T, + pub(in crate::intent_executor) task_info_fetcher: + &'a CacheTaskInfoFetcher, + pub(in crate::intent_executor) committed_pubkeys: &'a [Pubkey], +} + +#[async_trait] +impl<'a, 'e, A, T, F, O> StageExecutor for CommitStage<'a, 'e, A, T, F, O> +where + A: ActionsCallbackScheduler, + T: TransactionPreparator, + F: TaskInfoFetcher, + O: OutboxClient, + O::Error: Into, +{ + fn has_callbacks(&self) -> bool { + self.inner.has_callbacks() + } + + async fn execute(&mut self) -> IntentExecutorResult { + self.inner + .commit( + self.committed_pubkeys, + self.transaction_preparator, + self.task_info_fetcher, + ) + .await + } + + fn execute_callbacks( + &mut self, + signature: Option, + result: ActionResult, + ) { + self.inner.execute_callbacks(signature, result) + } +} + +pub(in crate::intent_executor) struct FinalizeStage<'a, 'e, A, T, O> { + pub(in crate::intent_executor) inner: + &'a mut TwoStageStrategyExecutor<'e, A, O, Committed>, + pub(in crate::intent_executor) transaction_preparator: &'a T, +} + +#[async_trait] +impl<'a, 'e, A, T, O> StageExecutor for FinalizeStage<'a, 'e, A, T, O> +where + A: ActionsCallbackScheduler, + T: TransactionPreparator, + O: OutboxClient, + O::Error: Into, +{ + fn has_callbacks(&self) -> bool { + self.inner.has_callbacks() + } + + async fn execute(&mut self) -> IntentExecutorResult { + self.inner.finalize(self.transaction_preparator).await + } + + fn execute_callbacks( + &mut self, + signature: Option, + result: ActionResult, + ) { + self.inner.execute_callbacks(signature, result) + } +} diff --git a/magicblock-committor-service/src/intent_executor/two_stage_executor.rs b/magicblock-committor-service/src/intent_executor/two_stage_executor.rs deleted file mode 100644 index 196ae72be..000000000 --- a/magicblock-committor-service/src/intent_executor/two_stage_executor.rs +++ /dev/null @@ -1,527 +0,0 @@ -use std::{mem, ops::ControlFlow}; - -use magicblock_core::traits::{ActionError, ActionsCallbackScheduler}; -use solana_keypair::Keypair; -use solana_pubkey::Pubkey; -use solana_signature::Signature; -use solana_signer::Signer; -use tracing::{error, instrument, warn}; - -use crate::{ - intent_executor::{ - error::{ - IntentExecutorError, IntentExecutorResult, - TransactionStrategyExecutionError, - }, - intent_execution_client::IntentExecutionClient, - task_info_fetcher::{CacheTaskInfoFetcher, TaskInfoFetcher}, - two_stage_executor::sealed::Sealed, - utils::{ - handle_actions_result, handle_commit_id_error, - handle_undelegation_error, prepare_and_execute_strategy, - }, - IntentExecutionReport, - }, - persist::{IntentPersister, IntentPersisterImpl}, - tasks::{task_strategist::TransactionStrategy, BaseTaskImpl, FinalizeTask}, - transaction_preparator::TransactionPreparator, -}; - -pub struct Initialized { - /// Commit stage strategy - commit_strategy: TransactionStrategy, - /// Finalize stage strategy - finalize_strategy: TransactionStrategy, - - current_attempt: u8, -} - -pub struct Committed { - /// Signature of commit stage - commit_signature: Signature, - /// Finalize stage strategy - finalize_strategy: TransactionStrategy, - - current_attempt: u8, -} - -pub struct Finalized { - /// Signature of commit stage - pub commit_signature: Signature, - /// Signature of finalize stage - pub finalize_signature: Signature, -} - -pub struct TwoStageExecutor<'a, A, S: Sealed> { - state: S, - authority: Keypair, - intent_client: IntentExecutionClient, - callback_scheduler: A, - execution_report: &'a mut IntentExecutionReport, -} - -impl<'a, A> TwoStageExecutor<'a, A, Initialized> -where - A: ActionsCallbackScheduler, -{ - const RECURSION_CEILING: u8 = 10; - - pub fn new( - authority: Keypair, - commit_strategy: TransactionStrategy, - finalize_strategy: TransactionStrategy, - intent_client: IntentExecutionClient, - callback_scheduler: A, - execution_report: &'a mut IntentExecutionReport, - ) -> Self { - Self { - authority, - intent_client, - execution_report, - callback_scheduler, - state: Initialized { - commit_strategy, - finalize_strategy, - current_attempt: 0, - }, - } - } - - #[instrument( - skip( - self, - committed_pubkeys, - transaction_preparator, - task_info_fetcher, - persister - ), - fields(stage = "commit") - )] - pub async fn commit( - &mut self, - committed_pubkeys: &[Pubkey], - transaction_preparator: &T, - task_info_fetcher: &CacheTaskInfoFetcher, - persister: &Option

, - ) -> IntentExecutorResult - where - T: TransactionPreparator, - F: TaskInfoFetcher, - P: IntentPersister, - { - let commit_result = loop { - self.state.current_attempt += 1; - - // Prepare & execute message - let execution_result = prepare_and_execute_strategy( - &self.intent_client, - &self.authority, - transaction_preparator, - &mut self.state.commit_strategy, - persister, - ) - .await - .map_err(IntentExecutorError::FailedCommitPreparationError)?; - let execution_err = match execution_result { - Ok(value) => break Ok(value), - Err(err) => err, - }; - - let flow = self - .patch_commit_strategy( - &execution_err, - task_info_fetcher, - transaction_preparator, - committed_pubkeys, - ) - .await?; - let cleanup = match flow { - ControlFlow::Continue(value) => value, - ControlFlow::Break(()) => { - break Err(execution_err); - } - }; - self.intent_client.invalidate_cached_blockhash().await; - self.execution_report.dispose(cleanup); - - if self.state.current_attempt >= Self::RECURSION_CEILING { - error!("CRITICAL! Recursion ceiling reached"); - break Err(execution_err); - } else { - self.execution_report.add_patched_error(execution_err); - } - }; - - self.execute_callbacks( - commit_result.as_ref().ok().copied(), - commit_result.as_ref().map(|_| ()), - ); - self.execution_report - .dispose(mem::take(&mut self.state.commit_strategy)); - if commit_result.is_err() { - self.execution_report - .dispose(mem::take(&mut self.state.finalize_strategy)); - } - commit_result.map_err(|err| { - IntentExecutorError::from_commit_execution_error(err) - }) - } - - /// Patches Commit stage `transaction_strategy` in response to a recoverable - /// [`TransactionStrategyExecutionError`], optionally preparing cleanup data - /// to be applied after a retry. - /// - /// [`TransactionStrategyExecutionError`], returning either: - /// - `Continue(to_cleanup)` when a retry should be attempted with cleanup metadata, or - /// - `Break(())` when this stage cannot be recovered. - pub async fn patch_commit_strategy( - &mut self, - err: &TransactionStrategyExecutionError, - task_info_fetcher: &CacheTaskInfoFetcher, - transaction_preparator: &T, - committed_pubkeys: &[Pubkey], - ) -> IntentExecutorResult> - where - T: TransactionPreparator, - F: TaskInfoFetcher, - { - match err { - TransactionStrategyExecutionError::CommitIDError(_, _) => { - let to_cleanup = handle_commit_id_error( - &self.authority.pubkey(), - task_info_fetcher, - committed_pubkeys, - &mut self.state.commit_strategy - ) - .await?; - Ok(ControlFlow::Continue(to_cleanup)) - } - err - @ TransactionStrategyExecutionError::UnfinalizedAccountError( - _, - signature, - ) => { - let optimized_tasks = - self.state.commit_strategy.optimized_tasks.as_slice(); - let task_index = err.task_index(); - if let Some(delegated_account) = task_index - .and_then(|index| optimized_tasks.get(index as usize)) - .and_then(|task| match task { - BaseTaskImpl::Commit(task) => { - Some(task.committed_account.pubkey) - } - BaseTaskImpl::CommitFinalize(task) => { - Some(task.committed_account.pubkey) - } - _ => None, - }) - { - self.handle_unfinalized_account_error( - signature, - delegated_account, - transaction_preparator, - ) - .await - } else { - error!( - task_index = ?task_index, - optimized_tasks_len = optimized_tasks.len(), - error = ?err, - "RPC returned unexpected task index" - ); - Ok(ControlFlow::Break(())) - } - } - TransactionStrategyExecutionError::ActionsError(err, signature) => { - // Intent bundles allow for actions to be in commit stage - let action_error = Err(ActionError::ActionsError(err.clone(), *signature)); - let to_cleanup = handle_actions_result( - &self.authority.pubkey(), - &self.callback_scheduler, - self.execution_report, - &mut self.state.commit_strategy, - *signature, - action_error - ); - Ok(ControlFlow::Continue(to_cleanup)) - } - TransactionStrategyExecutionError::UndelegationError(_, _) => { - // Unexpected in Two Stage commit - // That would mean that Two Stage executes undelegation in commit phase - error!(error = ?err, "Unexpected error in two stage commit flow"); - Ok(ControlFlow::Break(())) - } - TransactionStrategyExecutionError::CpiLimitError(_, _) - | TransactionStrategyExecutionError::LoadedAccountsDataSizeExceeded( - _, - _, - ) - | TransactionStrategyExecutionError::TransactionTooLargeError(_) => { - // Can't be handled - error!(error = ?err, "Commit tasks exceeded execution limit"); - Ok(ControlFlow::Break(())) - } - TransactionStrategyExecutionError::InternalError(_) => { - // Can't be handled - Ok(ControlFlow::Break(())) - } - } - } - - /// Handles unfinalized account error - /// Sends a separate tx to finalize account and then continues execution - async fn handle_unfinalized_account_error( - &self, - failed_signature: &Option, - delegated_account: Pubkey, - transaction_preparator: &T, - ) -> IntentExecutorResult> { - let finalize_task: BaseTaskImpl = - FinalizeTask { delegated_account }.into(); - prepare_and_execute_strategy( - &self.intent_client, - &self.authority, - transaction_preparator, - &mut TransactionStrategy { - optimized_tasks: vec![finalize_task], - lookup_tables_keys: vec![], - standalone_action_nonce: None, - }, - &None::, - ) - .await - .map_err(IntentExecutorError::FailedCommitPreparationError)? - .map_err(|err| IntentExecutorError::FailedToCommitError { - err, - signature: *failed_signature, - })?; - - Ok(ControlFlow::Continue(TransactionStrategy { - optimized_tasks: vec![], - lookup_tables_keys: vec![], - standalone_action_nonce: None, - })) - } - - pub fn has_callbacks(&self) -> bool { - self.state.commit_strategy.has_actions_callbacks() - || self.state.finalize_strategy.has_actions_callbacks() - } - - /// On `Err`: removes actions from both commit and finalize strategies and - /// executes all their callbacks with the error. - /// On `Ok`: removes actions only from commit strategy and executes their - /// callbacks, preserving finalize-stage actions for the finalize phase. - pub fn execute_callbacks( - &mut self, - signature: Option, - result: Result<(), impl Into>, - ) { - let result = result.map(|_| ()).map_err(|err| err.into()); - let junk_strategy = handle_actions_result( - &self.authority.pubkey(), - &self.callback_scheduler, - self.execution_report, - &mut self.state.commit_strategy, - signature, - result.clone(), - ); - self.execution_report.dispose(junk_strategy); - - if result.is_err() { - let junk_strategy = handle_actions_result( - &self.authority.pubkey(), - &self.callback_scheduler, - self.execution_report, - &mut self.state.finalize_strategy, - signature, - result, - ); - self.execution_report.dispose(junk_strategy); - } - } - - /// Transitions to next executor state - pub fn done( - self, - commit_signature: Signature, - ) -> TwoStageExecutor<'a, A, Committed> { - TwoStageExecutor { - authority: self.authority, - intent_client: self.intent_client, - callback_scheduler: self.callback_scheduler, - execution_report: self.execution_report, - state: Committed { - commit_signature, - finalize_strategy: self.state.finalize_strategy, - current_attempt: 0, - }, - } - } -} - -impl<'a, A> TwoStageExecutor<'a, A, Committed> -where - A: ActionsCallbackScheduler, -{ - const RECURSION_CEILING: u8 = 10; - - #[instrument( - skip(self, transaction_preparator, persister), - fields(stage = "finalize") - )] - pub async fn finalize( - &mut self, - transaction_preparator: &T, - persister: &Option

, - ) -> IntentExecutorResult - where - T: TransactionPreparator, - P: IntentPersister, - { - let finalize_result = loop { - self.state.current_attempt += 1; - - // Prepare & execute message - let execution_result = prepare_and_execute_strategy( - &self.intent_client, - &self.authority, - transaction_preparator, - &mut self.state.finalize_strategy, - persister, - ) - .await - .map_err(IntentExecutorError::FailedFinalizePreparationError)?; - let execution_err = match execution_result { - Ok(value) => break Ok(value), - Err(err) => err, - }; - - let flow = self.patch_finalize_strategy(&execution_err).await?; - - let cleanup = match flow { - ControlFlow::Continue(cleanup) => cleanup, - ControlFlow::Break(()) => { - break Err(execution_err); - } - }; - self.intent_client.invalidate_cached_blockhash().await; - self.execution_report.dispose(cleanup); - - if self.state.current_attempt >= Self::RECURSION_CEILING { - error!("CRITICAL! Recursion ceiling reached"); - break Err(execution_err); - } else { - self.execution_report.add_patched_error(execution_err); - } - }; - - // Even if failed - dump finalize into junk - self.execute_callbacks( - finalize_result.as_ref().ok().copied(), - finalize_result.as_ref().map(|_| ()), - ); - self.execution_report - .dispose(mem::take(&mut self.state.finalize_strategy)); - finalize_result.map_err(|err| { - IntentExecutorError::from_finalize_execution_error( - err, - Some(self.state.commit_signature), - ) - }) - } - - pub fn has_callbacks(&self) -> bool { - self.state.finalize_strategy.has_actions_callbacks() - } - - /// Removes actions from finalize strategy - /// Executes callbacks - pub fn execute_callbacks( - &mut self, - signature: Option, - result: Result<(), impl Into>, - ) { - let junk_strategy = handle_actions_result( - &self.authority.pubkey(), - &self.callback_scheduler, - self.execution_report, - &mut self.state.finalize_strategy, - signature, - result.map_err(|err| err.into()), - ); - self.execution_report.dispose(junk_strategy); - } - - /// Patches Finalize stage `transaction_strategy` in response to a recoverable - /// [`TransactionStrategyExecutionError`], optionally preparing cleanup data - /// to be applied after a retry. - /// - /// [`TransactionStrategyExecutionError`], returning either: - /// - `Continue(to_cleanup)` when a retry should be attempted with cleanup metadata, or - /// - `Break(())` when this stage cannot be recovered. - pub async fn patch_finalize_strategy( - &mut self, - err: &TransactionStrategyExecutionError, - ) -> IntentExecutorResult> { - match err { - TransactionStrategyExecutionError::CommitIDError(_, _) - | TransactionStrategyExecutionError::UnfinalizedAccountError( - _, - _, - ) => { - // Unexpected error in Two Stage commit - error!(error = ?err, "Unexpected error in two stage finalize flow"); - Ok(ControlFlow::Break(())) - } - TransactionStrategyExecutionError::ActionsError(err, signature) => { - // Here we patch strategy for it to be retried in next iteration - // & we also record data that has to be cleaned up after patch - let action_error = Err(ActionError::ActionsError(err.clone(), *signature)); - let to_cleanup = handle_actions_result( - &self.authority.pubkey(), - &self.callback_scheduler, - self.execution_report, - &mut self.state.finalize_strategy, - *signature, - action_error - ); - Ok(ControlFlow::Continue(to_cleanup)) - } - TransactionStrategyExecutionError::UndelegationError(_, _) => { - // Here we patch strategy for it to be retried in next iteration - // & we also record data that has to be cleaned up after patch - let to_cleanup = - handle_undelegation_error(&self.authority.pubkey(), &mut self.state.finalize_strategy); - Ok(ControlFlow::Continue(to_cleanup)) - } - TransactionStrategyExecutionError::CpiLimitError(_, _) - | TransactionStrategyExecutionError::LoadedAccountsDataSizeExceeded(_, _) - | TransactionStrategyExecutionError::TransactionTooLargeError(_) => { - // Can't be handled - warn!(error = ?err, "Finalization tasks exceeded execution limit"); - Ok(ControlFlow::Break(())) - } - TransactionStrategyExecutionError::InternalError(_) => { - // Can't be handled - Ok(ControlFlow::Break(())) - } - } - } - - /// Transitions to next executor state - pub fn done(self, finalize_signature: Signature) -> Finalized { - Finalized { - commit_signature: self.state.commit_signature, - finalize_signature, - } - } -} - -mod sealed { - pub trait Sealed {} - - impl Sealed for super::Initialized {} - impl Sealed for super::Committed {} - impl Sealed for super::Finalized {} -} diff --git a/magicblock-committor-service/src/intent_executor/two_stage_intent_executor.rs b/magicblock-committor-service/src/intent_executor/two_stage_intent_executor.rs new file mode 100644 index 000000000..2d703ca15 --- /dev/null +++ b/magicblock-committor-service/src/intent_executor/two_stage_intent_executor.rs @@ -0,0 +1,282 @@ +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use magicblock_core::traits::ActionsCallbackScheduler; +use magicblock_program::{ + magic_scheduled_base_intent::ScheduledIntentBundle, + outbox::TwoStageProgress, validator::validator_authority, +}; +use solana_keypair::Keypair; +use solana_signature::Signature; +use solana_signer::Signer; + +use crate::{ + intent_executor::{ + cleanup_handle::CleanupHandle, + error::{IntentExecutorError, IntentExecutorResult}, + strategy_executor::{ + two_stage::{Committed, Initialized, TwoStageStrategyExecutor}, + utils::{ + execute_with_timeout, resolve_pending_signature, FinalizeStage, + }, + }, + utils::{build_commit_finalize_tasks, execute_two_stage_flow}, + ExecutionOutput, IntentExecutionReport, IntentExecutionResult, + IntentExecutor, IntentExecutorCtx, + }, + outbox::{OutboxClient, ScheduledBaseIntentMeta}, + tasks::{ + task_builder::{TaskBuilderImpl, TasksBuilder}, + task_info_fetcher::{ResetType, TaskInfoFetcher}, + task_strategist::{TaskStrategist, TwoStageExecutionMode}, + }, + transaction_preparator::TransactionPreparator, +}; + +pub struct TwoStageIntentExecutor { + authority: Keypair, + /// Current stage of TwoStage execution flow + stage: TwoStageProgress, + /// Intent Executor context + ctx: IntentExecutorCtx, + + /// Timeout for Intent's actions + pub actions_timeout: Duration, + /// Intent execution started at + pub started_at: Instant, +} + +impl TwoStageIntentExecutor +where + T: TransactionPreparator, + F: TaskInfoFetcher, + A: ActionsCallbackScheduler, + O: OutboxClient, + O::Error: Into, +{ + pub fn new( + ctx: IntentExecutorCtx, + actions_timeout: Duration, + stage: TwoStageProgress, + ) -> Self { + let authority = validator_authority(); + Self { + authority, + stage, + ctx, + + actions_timeout, + started_at: Instant::now(), + } + } + + fn time_left(&self) -> Option { + self.actions_timeout.checked_sub(self.started_at.elapsed()) + } + + /// Picks up execution from commit stage signature + async fn execute_committing_intent( + &mut self, + intent_bundle: ScheduledIntentBundle, + execution_report: &mut IntentExecutionReport, + ) -> IntentExecutorResult { + // This stage was chosen prior so we build tasks for it + // Build tasks for commit & finalize stages + let (commit_tasks, finalize_tasks) = build_commit_finalize_tasks( + &intent_bundle, + &self.ctx.task_info_fetcher, + ) + .await?; + + // As strategy was chosen build two stage + let TwoStageExecutionMode { + commit_stage, + finalize_stage, + } = TaskStrategist::build_two_stage( + commit_tasks, + finalize_tasks, + &self.authority.pubkey(), + )?; + + let state = Initialized::new(commit_stage, finalize_stage); + execute_two_stage_flow( + &self.ctx, + state, + &self.authority, + intent_bundle, + execution_report, + || self.time_left(), + ) + .await + } + + /// Picks up execution from pending finalize signature + async fn execute_finalizing_intent( + &mut self, + intent: ScheduledIntentBundle, + commit_signature: Signature, + execution_report: &mut IntentExecutionReport, + ) -> IntentExecutorResult { + let meta = ScheduledBaseIntentMeta::new(&intent); + // Commit succeeded so we skip those tasks all together + let finalize_tasks = TaskBuilderImpl::finalize_tasks( + &self.ctx.task_info_fetcher, + &intent, + ) + .await?; + + // Build strategy for finalize tasks + let finalize_strategy = TaskStrategist::build_strategy( + finalize_tasks, + &self.authority.pubkey(), + )?; + + let committed_state = + Committed::new(commit_signature, finalize_strategy); + let mut finalize_strategy_executor = + TwoStageStrategyExecutor::committed( + committed_state, + self.authority.insecure_clone(), + intent.id, + self.ctx.intent_client.clone(), + self.ctx.outbox_client.clone(), + self.ctx.actions_callback_executor.clone(), + execution_report, + ); + + let finalize_signature = execute_with_timeout( + self.time_left(), + FinalizeStage { + inner: &mut finalize_strategy_executor, + transaction_preparator: &self.ctx.transaction_preparator, + }, + ) + .await?; + + let finalized_stage = + finalize_strategy_executor.done(finalize_signature); + let result = Ok(ExecutionOutput::TwoStage { + commit_signature: finalized_stage.commit_signature, + finalize_signature: finalized_stage.finalize_signature, + }); + self.ctx + .outbox_client + .notify_commit_sent(meta, &result, execution_report) + .await + .map_err(Into::into)?; + + result + } + + async fn execute_inner( + &mut self, + intent: ScheduledIntentBundle, + execution_report: &mut IntentExecutionReport, + ) -> IntentExecutorResult { + let pending = *self.stage.pending_transaction(); + let succeeded = + resolve_pending_signature(&self.ctx.intent_client, &pending) + .await?; + + match (&self.stage, succeeded) { + // Signature wasn't confirmed - need to reexecute from commit + (TwoStageProgress::Committing(_), false) => { + self.execute_committing_intent(intent, execution_report) + .await + } + // Signature confirmed - commit was executed, finalizing... + (TwoStageProgress::Committing(commit), true) => { + self.execute_finalizing_intent( + intent, + commit.signature, + execution_report, + ) + .await + } + // Finalize didn't occur - execute + (TwoStageProgress::Finalizing { commit, .. }, false) => { + self.execute_finalizing_intent( + intent, + *commit, + execution_report, + ) + .await + } + // Finalize was already executed on a previous run - notify the + // outbox so it isn't left pending and rediscovered again. + (TwoStageProgress::Finalizing { commit, finalize }, true) => { + let output = Ok(ExecutionOutput::TwoStage { + commit_signature: *commit, + finalize_signature: finalize.signature, + }); + self.ctx + .outbox_client + .notify_commit_sent( + ScheduledBaseIntentMeta::new(&intent), + &output, + execution_report, + ) + .await + .map_err(Into::into)?; + + output + } + } + } +} + +#[async_trait] +impl IntentExecutor for TwoStageIntentExecutor +where + T: TransactionPreparator, + F: TaskInfoFetcher, + A: ActionsCallbackScheduler, + O: OutboxClient, + O::Error: Into, +{ + async fn execute( + mut self: Box, + intent: ScheduledIntentBundle, + ) -> (IntentExecutionResult, CleanupHandle) { + // Duplicates AcceptedIntentExecutor::execute + let pubkeys = intent.get_all_committed_pubkeys(); + let undelegated_pubkeys = intent.get_undelegated_pubkeys(); + + let mut execution_report = IntentExecutionReport::default(); + let result = self.execute_inner(intent, &mut execution_report).await; + + if !pubkeys.is_empty() { + if result.is_err() { + // We can't know what landed on chain, resync everything + self.ctx + .task_info_fetcher + .reset(ResetType::Specific(&pubkeys)); + } else if !undelegated_pubkeys.is_empty() { + // Only undelegated accounts' nonces become stale. Keep the + // rest cached: a chain re-fetch can race the just-landed + // finalize and reuse a nonce (buffer PDA collision). + self.ctx + .task_info_fetcher + .reset(ResetType::Specific(&undelegated_pubkeys)); + } + } + let close_buffers = result.is_ok(); + let junk = execution_report.junk; + let result = IntentExecutionResult { + inner: result, + patched_errors: execution_report.patched_errors, + callbacks_report: execution_report.callbacks_report, + #[cfg(feature = "dev-context-only-utils")] + successful_transaction_strategies: execution_report + .successful_transaction_strategies, + }; + let cleanup_handle = CleanupHandle::new( + self.authority, + junk, + close_buffers, + self.ctx.transaction_preparator, + ); + + (result, cleanup_handle) + } +} diff --git a/magicblock-committor-service/src/intent_executor/utils.rs b/magicblock-committor-service/src/intent_executor/utils.rs index b4f83517c..b575830ad 100644 --- a/magicblock-committor-service/src/intent_executor/utils.rs +++ b/magicblock-committor-service/src/intent_executor/utils.rs @@ -1,423 +1,202 @@ -use std::time::Duration; +use std::{sync::Arc, time::Duration}; -use async_trait::async_trait; -use magicblock_core::traits::{ - ActionError, ActionResult, ActionsCallbackScheduler, -}; +use futures_util::future::join; +use magicblock_core::traits::ActionsCallbackScheduler; +use magicblock_program::magic_scheduled_base_intent::ScheduledIntentBundle; use solana_keypair::Keypair; -use solana_pubkey::Pubkey; -use solana_signature::Signature; -use tokio::time::timeout; -use tracing::info; +use solana_signer::Signer; use crate::{ intent_executor::{ - error::{IntentExecutorResult, TransactionStrategyExecutionError}, - intent_execution_client::IntentExecutionClient, - single_stage_executor::SingleStageExecutor, - task_info_fetcher::{CacheTaskInfoFetcher, ResetType, TaskInfoFetcher}, - two_stage_executor::{Committed, Initialized, TwoStageExecutor}, - IntentExecutionReport, + error::{IntentExecutorError, IntentExecutorResult}, + strategy_executor::{ + single_stage::SingleStageStrategyExecutor, + two_stage, + two_stage::TwoStageStrategyExecutor, + utils::{ + execute_with_timeout, handle_cpi_limit_error, CommitStage, + FinalizeStage, SingleStage, + }, + }, + ExecutionOutput, IntentExecutionReport, IntentExecutorCtx, }, - persist::IntentPersister, + outbox::{OutboxClient, ScheduledBaseIntentMeta}, tasks::{ - task_builder::TaskBuilderError, - task_strategist::{TaskStrategist, TransactionStrategy}, + task_builder::{TaskBuilderImpl, TasksBuilder}, + task_info_fetcher::TaskInfoFetcher, + task_strategist::TransactionStrategy, BaseTaskImpl, }, - transaction_preparator::{ - error::TransactionPreparatorError, TransactionPreparator, - }, + transaction_preparator::TransactionPreparator, }; -pub async fn prepare_and_execute_strategy( - client: &IntentExecutionClient, - authority: &Keypair, - transaction_preparator: &T, - transaction_strategy: &mut TransactionStrategy, - persister: &Option

, -) -> IntentExecutorResult< - IntentExecutorResult, - TransactionPreparatorError, -> -where - T: TransactionPreparator, - P: IntentPersister, -{ - let prepared_message = transaction_preparator - .prepare_for_strategy(authority, transaction_strategy, persister) - .await?; - - let execution_result = client - .execute_message_with_retries( - authority, - prepared_message, - &transaction_strategy.optimized_tasks, - ) - .await; - - Ok(execution_result) -} - -/// Handles out of sync commit id error, fixes current strategy -/// Returns strategy to be cleaned up -/// TODO(edwin): TransactionStrategy -> CleanupStrategy or something, naming is confusing for something that is cleaned up -pub(in crate::intent_executor) async fn handle_commit_id_error< - T: TaskInfoFetcher, +pub(in crate::intent_executor) async fn build_commit_finalize_tasks< + F: TaskInfoFetcher, >( - authority: &Pubkey, - task_info_fetcher: &CacheTaskInfoFetcher, - committed_pubkeys: &[Pubkey], - strategy: &mut TransactionStrategy, -) -> Result { - let min_context_slot = strategy - .optimized_tasks - .iter() - .filter_map(|task| match task { - BaseTaskImpl::Commit(task) => { - Some(task.committed_account.remote_slot) - } - BaseTaskImpl::CommitFinalize(task) => { - Some(task.committed_account.remote_slot) - } - _ => None, - }) - .max() - .unwrap_or_default(); - - // We reset TaskInfoFetcher for all committed accounts - // We re-fetch them to fix out of sync tasks - task_info_fetcher.reset(ResetType::Specific(committed_pubkeys)); - let commit_ids = task_info_fetcher - .fetch_next_commit_nonces(committed_pubkeys, min_context_slot) - .await - .map_err(TaskBuilderError::CommitTasksBuildError)?; - - // Here we find the broken tasks and reset them - // Broken tasks are prepared incorrectly so they have to be cleaned up - let mut to_cleanup = Vec::new(); - for task in &mut strategy.optimized_tasks { - match task { - BaseTaskImpl::Commit(task) => { - let Some(commit_id) = - commit_ids.get(&task.committed_account.pubkey) - else { - continue; - }; - if commit_id == &task.commit_id { - continue; - } - - // Handle invalid tasks - to_cleanup.push(BaseTaskImpl::Commit(task.clone())); - task.reset_commit_id(*commit_id); - } - BaseTaskImpl::CommitFinalize(task) => { - let Some(commit_id) = - commit_ids.get(&task.committed_account.pubkey) - else { - continue; - }; - if commit_id == &task.commit_id { - continue; - } - - // Handle invalid tasks - to_cleanup.push(BaseTaskImpl::CommitFinalize(task.clone())); - task.reset_commit_id(*commit_id); - } - _ => {} - } - } - - let old_alts = strategy.dummy_revaluate_alts(authority); - Ok(TransactionStrategy { - optimized_tasks: to_cleanup, - lookup_tables_keys: old_alts, - standalone_action_nonce: None, - }) + intent_bundle: &ScheduledIntentBundle, + task_info_fetcher: &Arc, +) -> IntentExecutorResult<(Vec, Vec)> { + let commit_tasks_fut = + TaskBuilderImpl::commit_tasks(task_info_fetcher, intent_bundle); + let finalize_tasks_fut = + TaskBuilderImpl::finalize_tasks(task_info_fetcher, intent_bundle); + let (commit_tasks, finalize_tasks) = + join(commit_tasks_fut, finalize_tasks_fut).await; + + Ok((commit_tasks?, finalize_tasks?)) } -/// Handle CPI limit error, splits single strategy flow into 2 -/// Returns Commit stage strategy, Finalize stage strategy and strategy to clean up -pub(in crate::intent_executor) fn handle_cpi_limit_error( - authority: &Pubkey, - strategy: TransactionStrategy, -) -> ( - TransactionStrategy, - TransactionStrategy, - TransactionStrategy, -) { - // We encountered error "Max instruction trace length exceeded" - // All the tasks a prepared to be executed at this point - // We attempt Two stages commit flow, need to split tasks up - let last_commit_ind = strategy.optimized_tasks.iter().rposition(|el| { - matches!( - el, - BaseTaskImpl::Commit(_) | BaseTaskImpl::CommitFinalize(_) - ) - }); - let (mut commit_stage_tasks, mut finalize_stage_tasks) = (vec![], vec![]); - for (i, el) in strategy.optimized_tasks.into_iter().enumerate() { - if Some(i) <= last_commit_ind { - commit_stage_tasks.push(el); - } else { - finalize_stage_tasks.push(el); - } - } - - let commit_alt_pubkeys = if strategy.lookup_tables_keys.is_empty() { - vec![] - } else { - TaskStrategist::collect_lookup_table_keys( - authority, - &commit_stage_tasks, - ) - }; - let commit_strategy = TransactionStrategy { - optimized_tasks: commit_stage_tasks, - lookup_tables_keys: commit_alt_pubkeys, - standalone_action_nonce: None, - }; - - let finalize_alt_pubkeys = if strategy.lookup_tables_keys.is_empty() { - vec![] - } else { - TaskStrategist::collect_lookup_table_keys( - authority, - &finalize_stage_tasks, - ) - }; - let finalize_strategy = TransactionStrategy { - optimized_tasks: finalize_stage_tasks, - lookup_tables_keys: finalize_alt_pubkeys, - standalone_action_nonce: None, - }; - - // We clean up only ALTs - let to_cleanup = TransactionStrategy { - optimized_tasks: vec![], - lookup_tables_keys: strategy.lookup_tables_keys, - standalone_action_nonce: None, - }; - - (commit_strategy, finalize_strategy, to_cleanup) -} - -/// Handles undelegation error, stripping away actions -/// Returns [`TransactionStrategy`] to be cleaned up -pub(in crate::intent_executor) fn handle_undelegation_error( - authority: &Pubkey, - strategy: &mut TransactionStrategy, -) -> TransactionStrategy { - let position = strategy - .optimized_tasks - .iter() - .position(|el| matches!(el, BaseTaskImpl::Undelegate(_))); - - if let Some(position) = position { - // Remove everything after undelegation including post undelegation actions - let removed_task = strategy.optimized_tasks.drain(position..).collect(); - let old_alts = strategy.dummy_revaluate_alts(authority); - TransactionStrategy { - optimized_tasks: removed_task, - lookup_tables_keys: old_alts, - standalone_action_nonce: None, - } - } else { - TransactionStrategy { - optimized_tasks: vec![], - lookup_tables_keys: vec![], - standalone_action_nonce: None, - } - } -} - -pub(in crate::intent_executor) fn handle_actions_result( - authority: &Pubkey, - callback_scheduler: &A, +pub(in crate::intent_executor) async fn execute_single_stage_flow( + ctx: &IntentExecutorCtx, + authority: &Keypair, + intent_bundle: ScheduledIntentBundle, + transaction_strategy: TransactionStrategy, execution_report: &mut IntentExecutionReport, - transaction_strategy: &mut TransactionStrategy, - signature: Option, - result: ActionResult, -) -> TransactionStrategy -where - A: ActionsCallbackScheduler, -{ - let (callbacks, junk) = if result.is_ok() { - let callbacks = transaction_strategy.extract_action_callbacks(); - (callbacks, TransactionStrategy::default()) - } else { - let mut removed_actions = - transaction_strategy.remove_actions(authority); - let callbacks = removed_actions.extract_action_callbacks(); - (callbacks, removed_actions) - }; - if !callbacks.is_empty() { - let result = callback_scheduler.schedule(callbacks, signature, result); - execution_report.add_callback_report(result); - } - - junk -} - -pub(in crate::intent_executor) async fn execute_with_timeout< - P: IntentPersister, ->( - time_left: Option, - mut executor: impl StageExecutor, - persister: &Option

, -) -> IntentExecutorResult { - if executor.has_callbacks() { - if let Some(time_left) = time_left { - match timeout(time_left, executor.execute(persister)).await { - Ok(res) => return res, - Err(_) => { - // The race between callback and intent txn is handled - // on the user smart contract side via TimeoutError. - // We must respect the timeout contract. - info!("Intent execution timed out, cleaning up actions"); - executor.execute_callbacks( - None, - Err(ActionError::TimeoutError), - ); - } - } - } else { - // Already timed out; see comment above. - executor.execute_callbacks(None, Err(ActionError::TimeoutError)); - } - } - - executor.execute(persister).await -} - -#[async_trait] -pub(in crate::intent_executor) trait StageExecutor { - fn has_callbacks(&self) -> bool; - async fn execute( - &mut self, - persister: &Option

, - ) -> IntentExecutorResult; - fn execute_callbacks( - &mut self, - signature: Option, - result: ActionResult, - ); -} - -pub(in crate::intent_executor) struct SingleStage<'a, 'e, A, T, F> { - pub(in crate::intent_executor) inner: &'a mut SingleStageExecutor<'e, F, A>, - pub(in crate::intent_executor) transaction_preparator: &'a T, - pub(in crate::intent_executor) committed_pubkeys: &'a [Pubkey], -} - -#[async_trait] -impl<'a, 'e, A, T, F> StageExecutor for SingleStage<'a, 'e, A, T, F> + time_left: impl Fn() -> Option, +) -> IntentExecutorResult where - A: ActionsCallbackScheduler, T: TransactionPreparator, F: TaskInfoFetcher, + A: ActionsCallbackScheduler, + O: OutboxClient, + O::Error: Into, { - fn has_callbacks(&self) -> bool { - self.inner.has_callbacks() - } - - async fn execute( - &mut self, - persister: &Option

, - ) -> IntentExecutorResult { - self.inner - .execute( - self.committed_pubkeys, - self.transaction_preparator, - persister, - ) - .await - } - - fn execute_callbacks( - &mut self, - signature: Option, - result: ActionResult, - ) { - self.inner.execute_callbacks(signature, result) - } -} + let meta = ScheduledBaseIntentMeta::new(&intent_bundle); + let committed_pubkeys = intent_bundle.get_all_committed_pubkeys(); + + let mut single_stage_executor = SingleStageStrategyExecutor::new( + authority.insecure_clone(), + intent_bundle.id, + ctx.intent_client.clone(), + ctx.task_info_fetcher.clone(), + ctx.outbox_client.clone(), + transaction_strategy, + ctx.actions_callback_executor.clone(), + execution_report, + ); + let res = execute_with_timeout( + time_left(), + SingleStage { + inner: &mut single_stage_executor, + transaction_preparator: &ctx.transaction_preparator, + committed_pubkeys: &committed_pubkeys, + }, + ) + .await; + + // Here we continue only IF the error is a limit-type execution error + // We can recover that Error by splitting execution + // in 2 stages - commit & finalize + // Otherwise we return error + let execution_err = match res { + Err(IntentExecutorError::FailedToFinalizeError { + err, + commit_signature: _, + finalize_signature: _, + }) if !committed_pubkeys.is_empty() + && err.is_recoverable_by_two_stage() => + { + err + } + res => { + let signature = res.as_ref().ok().copied(); + single_stage_executor + .execute_callbacks(signature, res.as_ref().map(|_| ())); + let transaction_strategy = single_stage_executor.consume_strategy(); + #[cfg(feature = "dev-context-only-utils")] + execution_report.add_succeeded_transaction_strategy( + transaction_strategy.clone(), + ); + execution_report.dispose(transaction_strategy); + let output = res.map(ExecutionOutput::SingleStage); + ctx.outbox_client + .notify_commit_sent(meta, &output, execution_report) + .await + .map_err(Into::into)?; + return output; + } + }; -pub(in crate::intent_executor) struct CommitStage<'a, 'e, A, T, F> { - pub(in crate::intent_executor) inner: - &'a mut TwoStageExecutor<'e, A, Initialized>, - pub(in crate::intent_executor) transaction_preparator: &'a T, - pub(in crate::intent_executor) task_info_fetcher: - &'a CacheTaskInfoFetcher, - pub(in crate::intent_executor) committed_pubkeys: &'a [Pubkey], + // With actions, we can't predict num of CPIs + // If we get here we will try to switch from Single stage to Two Stage commit + // Note that this not necessarily will pass at the end due to the same reason + let strategy = single_stage_executor.consume_strategy(); + let (commit_strategy, finalize_strategy, cleanup) = + handle_cpi_limit_error(&authority.pubkey(), strategy); + execution_report.dispose(cleanup); + execution_report.add_patched_error(execution_err); + + // Create state for two stage flow + // Pending signature set to None as if we're here tx failed + let state = two_stage::Initialized::new(commit_strategy, finalize_strategy); + + execute_two_stage_flow( + ctx, + state, + authority, + intent_bundle, + execution_report, + time_left, + ) + .await } -#[async_trait] -impl<'a, 'e, A, T, F> StageExecutor for CommitStage<'a, 'e, A, T, F> +pub(in crate::intent_executor) async fn execute_two_stage_flow( + ctx: &IntentExecutorCtx, + state: two_stage::Initialized, + authority: &Keypair, + intent_bundle: ScheduledIntentBundle, + execution_report: &mut IntentExecutionReport, + time_left: impl Fn() -> Option, +) -> IntentExecutorResult where - A: ActionsCallbackScheduler, T: TransactionPreparator, F: TaskInfoFetcher, -{ - fn has_callbacks(&self) -> bool { - self.inner.has_callbacks() - } - - async fn execute( - &mut self, - persister: &Option

, - ) -> IntentExecutorResult { - self.inner - .commit( - self.committed_pubkeys, - self.transaction_preparator, - self.task_info_fetcher, - persister, - ) - .await - } - - fn execute_callbacks( - &mut self, - signature: Option, - result: ActionResult, - ) { - self.inner.execute_callbacks(signature, result) - } -} - -pub(in crate::intent_executor) struct FinalizeStage<'a, 'e, A, T> { - pub(in crate::intent_executor) inner: - &'a mut TwoStageExecutor<'e, A, Committed>, - pub(in crate::intent_executor) transaction_preparator: &'a T, -} - -#[async_trait] -impl<'a, 'e, A, T> StageExecutor for FinalizeStage<'a, 'e, A, T> -where A: ActionsCallbackScheduler, - T: TransactionPreparator, + O: OutboxClient, + O::Error: Into, { - fn has_callbacks(&self) -> bool { - self.inner.has_callbacks() - } - - async fn execute( - &mut self, - persister: &Option

, - ) -> IntentExecutorResult { - self.inner - .finalize(self.transaction_preparator, persister) - .await - } + let meta = ScheduledBaseIntentMeta::new(&intent_bundle); + let committed_pubkeys = intent_bundle.get_all_committed_pubkeys(); + let mut executor = TwoStageStrategyExecutor::new( + state, + authority.insecure_clone(), + intent_bundle.id, + ctx.intent_client.clone(), + ctx.outbox_client.clone(), + ctx.actions_callback_executor.clone(), + execution_report, + ); - fn execute_callbacks( - &mut self, - signature: Option, - result: ActionResult, - ) { - self.inner.execute_callbacks(signature, result) - } + let commit_signature = execute_with_timeout( + time_left(), + CommitStage { + inner: &mut executor, + transaction_preparator: &ctx.transaction_preparator, + task_info_fetcher: &ctx.task_info_fetcher, + committed_pubkeys: &committed_pubkeys, + }, + ) + .await?; + + let mut finalize_executor = executor.done(commit_signature); + let finalize_signature = execute_with_timeout( + time_left(), + FinalizeStage { + inner: &mut finalize_executor, + transaction_preparator: &ctx.transaction_preparator, + }, + ) + .await?; + + let finalized_stage = finalize_executor.done(finalize_signature); + let output = ExecutionOutput::TwoStage { + commit_signature: finalized_stage.commit_signature, + finalize_signature: finalized_stage.finalize_signature, + }; + ctx.outbox_client + .notify_commit_sent(meta, &Ok(output), execution_report) + .await + .map_err(Into::into)?; + Ok(output) } diff --git a/magicblock-committor-service/src/lib.rs b/magicblock-committor-service/src/lib.rs index 48d90930f..82c8c4bb8 100644 --- a/magicblock-committor-service/src/lib.rs +++ b/magicblock-committor-service/src/lib.rs @@ -1,19 +1,17 @@ pub mod committor_processor; mod compute_budget; pub mod config; -mod consts; pub mod error; -pub mod intent_execution_manager; +pub mod intent_engine; pub mod intent_executor; -pub mod persist; pub mod tasks; pub mod transaction_preparator; -pub mod transactions; -pub(crate) mod utils; +pub mod outbox; pub mod service; #[cfg(test)] pub mod test_utils; +pub mod utils; pub use compute_budget::ComputeBudgetConfig; pub use config::DEFAULT_ACTIONS_TIMEOUT; diff --git a/magicblock-committor-service/src/outbox/mod.rs b/magicblock-committor-service/src/outbox/mod.rs new file mode 100644 index 000000000..71d3ff3b5 --- /dev/null +++ b/magicblock-committor-service/src/outbox/mod.rs @@ -0,0 +1,98 @@ +use async_trait::async_trait; +use magicblock_program::{ + magic_scheduled_base_intent::ScheduledIntentBundle, outbox::ExecutionStage, +}; +use solana_hash::Hash; +use solana_keypair::Address as Pubkey; +use solana_transaction::Transaction; + +use crate::{ + intent_executor::{ + error::IntentExecutorResult, ExecutionOutput, IntentExecutionReport, + }, + outbox::outbox_intent_bundles_reader::OutboxIntentBundlesReader, +}; + +pub mod outbox_client; +pub mod outbox_intent_bundles_reader; +pub(crate) mod utils; + +#[async_trait] +pub trait OutboxClient: Send + Sync + 'static { + type Error: std::error::Error + Send; + /// Type that is able to read IntentBundles from Outbox + /// Can be via AccountsDB, RpcClient or any other means + type OutboxReader: OutboxIntentBundlesReader; + + /// Executes `Accept` tx and returns accepted intents + async fn accept_scheduled_intents( + &self, + ) -> Result< + Vec, + (Vec, Self::Error), + >; + + /// Sets execution stage for outbox intent + /// Note: intent has to be accepted prior + /// Calling with invalid state transitions will lead to `TransactionError` + async fn set_intent_execution_stage( + &self, + intent_id: u64, + stage: ExecutionStage, + ) -> Result<(), Self::Error>; + + /// Processes intent results, submitting them on chain(ER) + async fn notify_commit_sent( + &self, + meta: ScheduledBaseIntentMeta, + result: &IntentExecutorResult, + execution_report: &IntentExecutionReport, + ) -> Result<(), Self::Error>; + + /// Returns reader capable of reading IntentBundles from Outbox + fn outbox_reader(&self) -> Self::OutboxReader; +} + +pub struct ScheduledBaseIntentMeta { + pub id: u64, + pub slot: u64, + pub blockhash: Hash, + pub payer: Pubkey, + pub included_pubkeys: Vec, + pub intent_sent_transaction: IntentSentTransaction, + pub requested_undelegation: bool, +} + +impl ScheduledBaseIntentMeta { + pub(crate) fn new(intent: &ScheduledIntentBundle) -> Self { + Self { + id: intent.id, + slot: intent.slot, + blockhash: intent.blockhash, + payer: intent.payer, + included_pubkeys: intent.get_all_committed_pubkeys(), + intent_sent_transaction: if intent + .sent_transaction + .signatures + .is_empty() + { + IntentSentTransaction::Recovered + } else { + IntentSentTransaction::Known(intent.sent_transaction.clone()) + }, + requested_undelegation: intent.has_undelegate_intent(), + } + } +} + +/// Tracks the `ScheduledCommitSent` transaction for an in-flight intent. +/// +/// Bundles recovered from the on-chain outbox have a stale `sent_transaction` +/// (original blockhash is expired by restart time). `Recovered` signals that +/// the transaction must be rebuilt at notification time with a fresh ER blockhash. +#[derive(Default)] +pub enum IntentSentTransaction { + Known(Transaction), + #[default] + Recovered, +} diff --git a/magicblock-committor-service/src/outbox/outbox_client.rs b/magicblock-committor-service/src/outbox/outbox_client.rs new file mode 100644 index 000000000..be85fdba3 --- /dev/null +++ b/magicblock-committor-service/src/outbox/outbox_client.rs @@ -0,0 +1,236 @@ +use std::{mem, num::NonZeroUsize, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use backoff::{future::retry, ExponentialBackoff}; +use magicblock_accounts_db::{traits::AccountsBank, AccountsDb}; +use magicblock_core::{ + link::{ + accounts::LockedAccount, + transactions::{with_encoded, TransactionSchedulerHandle}, + }, + traits::LatestBlockProvider, +}; +use magicblock_program::{ + instruction_utils::InstructionUtils, + magic_scheduled_base_intent::ScheduledIntentBundle, outbox::ExecutionStage, + register_scheduled_commit_sent, MagicContext, Pubkey, MAGIC_CONTEXT_PUBKEY, +}; +use solana_account::{Account, ReadableAccount}; +use solana_rpc_client::{ + nonblocking::rpc_client::RpcClient, rpc_client::SerializableTransaction, +}; +use solana_rpc_client_api::{ + client_error, client_error::ErrorKind as RpcClientErrorKind, +}; +use solana_transaction_error::TransactionError; +use tracing::{debug, error}; + +use crate::{ + intent_executor::{ + error::IntentExecutorResult, ExecutionOutput, IntentExecutionReport, + }, + outbox::{ + outbox_intent_bundles_reader::InternalOutboxIntentBundlesReader, + utils::build_sent_commit, IntentSentTransaction, OutboxClient, + ScheduledBaseIntentMeta, + }, +}; + +/// Implementation of `OutboxClient` that uses ER internals +/// Potentially could be replaced with RPC base Client +pub struct InternalOutboxClient { + /// Provides access to MagicContext + accounts_db: Arc, + /// RPC client for sending accept transactions to the ER + rpc_client: Arc, + /// Internal endpoint for scheduling ER TXs + transaction_scheduler: TransactionSchedulerHandle, + /// Provides access to ER latest block for TX creation + latest_block_provider: L, +} + +impl InternalOutboxClient { + pub fn new( + accounts_db: Arc, + rpc_client: Arc, + transaction_scheduler: TransactionSchedulerHandle, + latest_block_provider: L, + ) -> Self { + Self { + accounts_db, + rpc_client, + transaction_scheduler, + latest_block_provider, + } + } + + async fn send_with_backoff( + &self, + backoff_config: ExponentialBackoff, + tx: &impl SerializableTransaction, + ) -> Result<(), client_error::Error> { + let signature = tx.get_signature(); + retry(backoff_config, || async { + self.rpc_client + .send_and_confirm_transaction(tx) + .await + .map_err(|err| { + match err.kind() { + RpcClientErrorKind::TransactionError(_) => { + backoff::Error::Permanent(err) + } + _ => { + error!(signature = ?signature, error = ?err, "Transient error accepting intents, retrying"); + backoff::Error::transient(err) + } + } + }) + }).await?; + + Ok(()) + } + + /// Sends `AcceptScheduledCommits` transactions to the ER, moving scheduled + /// commits from `MagicContext` into outbox PDA accounts, up to CHUNK_SIZE intents per transaction. + /// On first error returns the successfully accepted intents so far alongside the error. + async fn send_accept_tx( + &self, + scheduled_intents: Vec, + ) -> Result< + Vec, + (Vec, InternalOutboxClientError), + > { + const CHUNK_SIZE: usize = 50; + + let mut remaining = scheduled_intents; + let mut accepted = Vec::with_capacity(remaining.len()); + while !remaining.is_empty() { + let chunk_size = CHUNK_SIZE.min(remaining.len()); + let tx = InstructionUtils::accept_scheduled_commits( + self.latest_block_provider.blockhash(), + remaining[..chunk_size].iter().map(|i| i.id), + ); + let backoff_config = ExponentialBackoff { + max_elapsed_time: Some(Duration::from_secs(25)), + max_interval: Duration::from_secs(5), + ..ExponentialBackoff::default() + }; + match self.send_with_backoff(backoff_config, &tx).await { + Ok(_) => accepted.extend(remaining.drain(..chunk_size)), + Err(err) => return Err((accepted, err.into())), + } + } + + Ok(accepted) + } + + /// Returns account corresponding to `pubkey` if it exists + /// Safely handles race-conditions and any concurrent changes to an account + fn safe_get_account(&self, pubkey: &Pubkey) -> Option { + let shared_account = self.accounts_db.get_account(pubkey)?; + let locked_account = LockedAccount::new(*pubkey, shared_account); + let output = locked_account + .read_locked(|_, account| Account::from(account.clone())); + + Some(output) + } +} + +#[async_trait] +impl OutboxClient for InternalOutboxClient { + type Error = InternalOutboxClientError; + type OutboxReader = InternalOutboxIntentBundlesReader; + + async fn accept_scheduled_intents( + &self, + ) -> Result< + Vec, + (Vec, Self::Error), + > { + // If accounts were scheduled to be committed, we accept them here + // and processs the commits + let magic_context_acc = + self.safe_get_account(&MAGIC_CONTEXT_PUBKEY).expect( + "Validator found to be running without MagicContext account!", + ); + + let magic_context = MagicContext::deserialize(magic_context_acc.data()) + .map_err(|err| (vec![], err.into()))?; + self.send_accept_tx(magic_context.scheduled_base_intents) + .await + } + + async fn set_intent_execution_stage( + &self, + intent_id: u64, + stage: ExecutionStage, + ) -> Result<(), Self::Error> { + let tx = InstructionUtils::set_intent_execution_stage( + self.latest_block_provider.blockhash(), + intent_id, + stage, + ); + + self.send_with_backoff( + ExponentialBackoff { + max_elapsed_time: Some(Duration::from_secs(25)), + max_interval: Duration::from_secs(5), + ..ExponentialBackoff::default() + }, + &tx, + ) + .await + .map_err(Into::into) + } + + async fn notify_commit_sent( + &self, + mut meta: ScheduledBaseIntentMeta, + result: &IntentExecutorResult, + execution_report: &IntentExecutionReport, + ) -> Result<(), Self::Error> { + let tx = match mem::take(&mut meta.intent_sent_transaction) { + IntentSentTransaction::Known(tx) => tx, + IntentSentTransaction::Recovered => { + let blockhash = self.latest_block_provider.blockhash(); + InstructionUtils::scheduled_commit_sent(meta.id, blockhash) + } + }; + let sent_commit = build_sent_commit(meta, result, execution_report); + // TODO(edwin): is using handle directly here ok? This could require Chainlink mechanics + register_scheduled_commit_sent(sent_commit); + let txn = with_encoded(tx).inspect_err(|err| { + // Unreachable case, all intent transactions are smaller than 64KB by construction + error!(error = ?err, "Failed to bincode intent transaction"); + })?; + self.transaction_scheduler + .execute(txn) + .await + .inspect(|_| debug!("Sent commit signaled")) + .inspect_err( + |err| error!(error = ?err, "Failed to signal sent commit"), + )?; + + Ok(()) + } + + fn outbox_reader(&self) -> Self::OutboxReader { + const CAPACITY: NonZeroUsize = NonZeroUsize::new(1000).unwrap(); + InternalOutboxIntentBundlesReader::new( + self.accounts_db.clone(), + CAPACITY, + ) + } +} + +#[derive(thiserror::Error, Debug)] +pub enum InternalOutboxClientError { + #[error("TransactionError: {0}")] + TransactionError(#[from] TransactionError), + #[error("RpcClientError: {0}")] + RpcClientError(#[from] client_error::Error), + #[error("BincodeError: {0}")] + BincodeError(#[from] bincode::Error), +} + +pub type InternalOutboxClientResult = Result; diff --git a/magicblock-committor-service/src/outbox/outbox_intent_bundles_reader.rs b/magicblock-committor-service/src/outbox/outbox_intent_bundles_reader.rs new file mode 100644 index 000000000..ee6cdf252 --- /dev/null +++ b/magicblock-committor-service/src/outbox/outbox_intent_bundles_reader.rs @@ -0,0 +1,327 @@ +use std::{ + cmp::Ordering, + collections::{BinaryHeap, VecDeque}, + num::NonZeroUsize, + sync::Arc, +}; + +use async_trait::async_trait; +use magicblock_accounts_db::{ + error::AccountsDbError, traits::AccountsBank, AccountsDb, +}; +use magicblock_core::intent::outbox::{ + outbox_intent_pda, OUTBOX_INTENT_DISCRIMINATOR, +}; +use magicblock_program::outbox_intent_bundles::OutboxIntentBundle; +use solana_account::{AccountSharedData, ReadableAccount}; +use tracing::warn; + +#[async_trait] +pub trait OutboxIntentBundlesReader: Send + 'static { + type Error: Send; + + /// Returns up to `n` outbox intents sorted ascending by `ScheduledIntentBundle::id`. + /// If `Vec::len() < n`, no more intents are available. + async fn read( + &mut self, + n: usize, + ) -> Result, Self::Error>; + + /// Fetches a single outbox intent by id. Returns `None` if the account does not exist. + async fn fetch_outbox_intent( + &self, + intent_id: u64, + ) -> Result, Self::Error>; +} + +pub struct InternalOutboxIntentBundlesReader { + /// Capacity of the buffer + capacity: NonZeroUsize, + /// Sorted ascending by intent id, drained from front on read() + buffer: VecDeque, + /// Max id returned so far; refill skips ids <= this + last_consumed_id: Option, + /// Accounts DB where we read intents from + /// Could be an RpcClient in the future + accounts_db: Arc, +} + +impl InternalOutboxIntentBundlesReader { + pub fn new(accounts_db: Arc, capacity: NonZeroUsize) -> Self { + Self { + capacity, + buffer: VecDeque::new(), + last_consumed_id: None, + accounts_db, + } + } + + // Refills buffer with OutboxIntents up to capacity + fn refill(&mut self) -> Result<(), AccountsDbError> { + #[repr(transparent)] + struct OrderedIntent { + inner: OutboxIntentBundle, + } + impl Eq for OrderedIntent {} + impl PartialEq for OrderedIntent { + fn eq(&self, other: &Self) -> bool { + self.inner.id.eq(&other.inner.id) + } + } + impl PartialOrd for OrderedIntent { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } + } + impl Ord for OrderedIntent { + fn cmp(&self, other: &Self) -> Ordering { + self.inner.id.cmp(&other.inner.id) + } + } + + let outbox_candidates_iter = self.accounts_db.get_program_accounts( + &magicblock_program::ID, + Self::outbox_accounts_filter, + )?; + + // Create iterator that yields valid, unconsumed intents + let outbox_iter = outbox_candidates_iter + .map(|(address, account)| { + (address, OutboxIntentBundle::try_from_bytes(account.data())) + }) + // Filter out failed deserializations + warn + .filter_map(|(pubkey, account_result)| match account_result { + Ok(value) => Some(value), + Err(err) => { + warn!(error = ?err, "Account with OutboxIntentBundle failed to deserialize, pubkey; {}", pubkey); + None + } + }) + // Filter out already consumed intents + .filter(|outbox_intent| if let Some(last_consumed_id) = self.last_consumed_id { + outbox_intent.id > last_consumed_id + } else { + true + }); + + // Retain only `capacity` of smallest id's intents + let capacity = self.capacity.get(); + let mut heap: BinaryHeap = + BinaryHeap::with_capacity(capacity); + for outbox_intent_bundle in outbox_iter { + heap.push(OrderedIntent { + inner: outbox_intent_bundle, + }); + if heap.len() > capacity { + heap.pop(); + } + } + + // SAFETY: OrderedIntent is #[repr(transparent)] over OutboxIntentBundle — + // identical memory layout, zero-copy cast + let mut items: Vec = unsafe { + let mut v = std::mem::ManuallyDrop::new(heap.into_vec()); + Vec::from_raw_parts( + v.as_mut_ptr() as *mut OutboxIntentBundle, + v.len(), + v.capacity(), + ) + }; + items.sort_unstable_by_key(|b| b.id); + self.last_consumed_id = + items.last().map(|b| b.id).or(self.last_consumed_id); + self.buffer.extend(items); + Ok(()) + } + + /// Filter for OutboxIntentBundle accounts, those start with OUTBOX_INTENT_DISCRIMINATOR + fn outbox_accounts_filter(account: &AccountSharedData) -> bool { + account.data().starts_with(&OUTBOX_INTENT_DISCRIMINATOR) + } +} + +#[async_trait] +impl OutboxIntentBundlesReader for InternalOutboxIntentBundlesReader { + type Error = OutboxIntentBundlesReaderError; + + /// Returns up to `n` outbox intents sorted ascending by `ScheduledIntentBundle::id`. + /// If `Vec::len() < n`, no more intents are available. + /// + /// When the internal buffer runs low, triggers a full `getProgramAccounts` scan + /// to refill — O(N log C) where N is open intent count and C is capacity. + /// Callers recommended to drive each read batch to completion and close the accounts + /// before reading again; closed accounts are removed from the DB and won't + /// be scanned on the next refill. + async fn read( + &mut self, + n: usize, + ) -> Result, Self::Error> { + if n == 0 { + return Ok(vec![]); + } + let capacity = self.capacity.get(); + if n > capacity { + return Err(Self::Error::ReadExceedsCapacityError(n, capacity)); + } + + // Refill if buffer runs low + if self.buffer.len() < n { + self.refill()?; + } + + // Take available amount + let take = n.min(self.buffer.len()); + Ok(self.buffer.drain(..take).collect()) + } + + async fn fetch_outbox_intent( + &self, + intent_id: u64, + ) -> Result, Self::Error> { + let pda = outbox_intent_pda(intent_id); + let Some(account) = self.accounts_db.get_account(&pda) else { + return Ok(None); + }; + Ok(Some(OutboxIntentBundle::try_from_bytes(account.data())?)) + } +} + +#[derive(thiserror::Error, Debug)] +pub enum OutboxIntentBundlesReaderError { + #[error("Requested read exceeded capacity, n: {0}, capacity: {1}")] + ReadExceedsCapacityError(usize, usize), + #[error("AccountsDbError: {0}")] + AccountsDbError(#[from] AccountsDbError), + #[error("BincodeError: {0}")] + BincodeError(#[from] bincode::Error), +} + +pub type OutboxIntentBundlesReaderResult = + Result; + +#[cfg(test)] +mod tests { + use std::{num::NonZeroUsize, sync::Arc}; + + use magicblock_accounts_db::AccountsDb; + use magicblock_core::intent::outbox::outbox_intent_pda; + use magicblock_program::{ + magic_scheduled_base_intent::{ + MagicIntentBundle, ScheduledIntentBundle, + }, + outbox_intent_bundles::OutboxIntentBundle, + }; + use solana_account::{AccountSharedData, WritableAccount}; + use solana_hash::Hash; + use solana_pubkey::Pubkey; + use solana_transaction::Transaction; + + use super::{InternalOutboxIntentBundlesReader, OutboxIntentBundlesReader}; + + fn make_db() -> (Arc, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("temp dir"); + let db = AccountsDb::open(dir.path()).expect("db init").into(); + (db, dir) + } + + fn make_bundle(id: u64) -> OutboxIntentBundle { + let inner = ScheduledIntentBundle { + id, + slot: 0, + blockhash: Hash::default(), + sent_transaction: Transaction::default(), + payer: Pubkey::default(), + intent_bundle: MagicIntentBundle::default(), + }; + OutboxIntentBundle::accepted(inner) + } + + fn insert_bundle(db: &AccountsDb, bundle: &OutboxIntentBundle) { + let bytes = bundle.try_to_bytes().expect("serialize"); + let pubkey = outbox_intent_pda(bundle.inner.id); + let mut account = + AccountSharedData::new(1, bytes.len(), &magicblock_program::ID); + account.data_as_mut_slice().copy_from_slice(&bytes); + db.insert_account(&pubkey, &account).expect("insert"); + } + + #[tokio::test] + async fn read_returns_ascending_order() { + let (db, _dir) = make_db(); + insert_bundle(&db, &make_bundle(9)); + insert_bundle(&db, &make_bundle(1)); + insert_bundle(&db, &make_bundle(4)); + + let mut reader = InternalOutboxIntentBundlesReader::new( + db, + NonZeroUsize::new(10).unwrap(), + ); + let result = reader.read(3).await.unwrap(); + assert_eq!(result.len(), 3); + assert_eq!(result[0].inner.id, 1); + assert_eq!(result[1].inner.id, 4); + assert_eq!(result[2].inner.id, 9); + } + + #[tokio::test] + async fn read_fewer_than_n_when_db_has_less() { + let (db, _dir) = make_db(); + insert_bundle(&db, &make_bundle(3)); + + let mut reader = InternalOutboxIntentBundlesReader::new( + db, + NonZeroUsize::new(10).unwrap(), + ); + let result = reader.read(5).await.unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].inner.id, 3); + } + + #[tokio::test] + async fn read_respects_capacity_smallest() { + let (db, _dir) = make_db(); + for id in [10, 5, 1, 8, 3] { + insert_bundle(&db, &make_bundle(id)); + } + + let mut reader = InternalOutboxIntentBundlesReader::new( + db, + NonZeroUsize::new(3).unwrap(), + ); + let result = reader.read(3).await.unwrap(); + assert_eq!( + result.iter().map(|b| b.inner.id).collect::>(), + vec![1, 3, 5] + ); + } + + #[tokio::test] + async fn read_exceeds_capacity_errors() { + let (db, _dir) = make_db(); + let mut reader = InternalOutboxIntentBundlesReader::new( + db, + NonZeroUsize::new(3).unwrap(), + ); + assert!(reader.read(4).await.is_err()); + } + + #[tokio::test] + async fn sequential_reads_dont_repeat() { + let (db, _dir) = make_db(); + for id in 1..=6 { + insert_bundle(&db, &make_bundle(id)); + } + + let mut reader = InternalOutboxIntentBundlesReader::new( + db, + NonZeroUsize::new(5).unwrap(), + ); + let first = reader.read(3).await.unwrap(); + let second = reader.read(3).await.unwrap(); + + let first_ids: Vec<_> = first.iter().map(|b| b.inner.id).collect(); + let second_ids: Vec<_> = second.iter().map(|b| b.inner.id).collect(); + assert_eq!(first_ids, vec![1, 2, 3]); + assert_eq!(second_ids, vec![4, 5, 6]); + } +} diff --git a/magicblock-committor-service/src/outbox/utils.rs b/magicblock-committor-service/src/outbox/utils.rs new file mode 100644 index 000000000..1e1f930a0 --- /dev/null +++ b/magicblock-committor-service/src/outbox/utils.rs @@ -0,0 +1,76 @@ +use magicblock_program::SentCommit; +use tracing::{error, info}; + +use crate::{ + intent_executor::{ + error::IntentExecutorResult, ExecutionOutput, IntentExecutionReport, + }, + outbox::ScheduledBaseIntentMeta, +}; + +pub(crate) fn build_sent_commit( + meta: ScheduledBaseIntentMeta, + result: &IntentExecutorResult, + execution_report: &IntentExecutionReport, +) -> SentCommit { + let error_message = result.as_ref().err().map(|err| format!("{:?}", err)); + + let chain_signatures = match result { + Ok(output) => match output { + ExecutionOutput::SingleStage(sig) => vec![*sig], + ExecutionOutput::TwoStage { + commit_signature, + finalize_signature, + } => vec![*commit_signature, *finalize_signature], + }, + Err(err) => { + error!( + "Failed to commit intent: {}, slot: {}, blockhash: {}. {:?}", + meta.id, meta.slot, meta.blockhash, err + ); + err.base_signatures() + .map(|(commit, finalize)| { + finalize.map(|f| vec![commit, f]).unwrap_or(vec![commit]) + }) + .unwrap_or_default() + } + }; + + let patched_errors = execution_report + .patched_errors() + .iter() + .map(|err| { + info!("Patched intent: {}. error was: {}", meta.id, err); + err.to_string() + }) + .collect(); + + let callbacks_scheduling_results = execution_report + .callbacks_report() + .iter() + .map(|r| match r { + Ok(sig) => format!("OK: {sig}"), + Err(err) => { + error!( + "Callback failed to schedule: {}. error: {}", + meta.id, err + ); + format!("ERR: {err}") + } + }) + .collect(); + + SentCommit { + message_id: meta.id, + slot: meta.slot, + blockhash: meta.blockhash, + payer: meta.payer, + chain_signatures, + included_pubkeys: meta.included_pubkeys, + excluded_pubkeys: vec![], + requested_undelegation: meta.requested_undelegation, + error_message, + patched_errors, + callbacks_scheduling_results, + } +} diff --git a/magicblock-committor-service/src/persist/commit_persister.rs b/magicblock-committor-service/src/persist/commit_persister.rs deleted file mode 100644 index 375dd7f59..000000000 --- a/magicblock-committor-service/src/persist/commit_persister.rs +++ /dev/null @@ -1,1067 +0,0 @@ -use std::{ - collections::BTreeMap, - path::Path, - sync::{Arc, Mutex}, -}; - -use magicblock_core::intent::CommittedAccount; -use magicblock_program::magic_scheduled_base_intent::{ - CommitAndUndelegate, CommitType as IntentCommitType, MagicIntentBundle, - ScheduledIntentBundle, UndelegateType, -}; -use solana_account::Account; -use solana_pubkey::Pubkey; -use solana_transaction::Transaction; -use tracing::warn; - -use super::{ - db::CommitStatusRow, error::CommitPersistResult, utils::now, CommitStatus, - CommitStrategy, CommitType, CommittsDb, MessageSignatures, -}; -use crate::{ - intent_executor::ExecutionOutput, persist::db::BundleSignatureRow, -}; - -const POISONED_MUTEX_MSG: &str = "Commitor Persister lock poisoned"; - -/// Records lifespan pf BaseIntent -pub trait IntentPersister: Send + Sync + Clone + 'static { - /// Starts persisting BaseIntents - fn start_base_intents( - &self, - base_intent: &[ScheduledIntentBundle], - ) -> CommitPersistResult<()>; - /// Starts persisting BaseIntent - fn start_base_intent( - &self, - base_intent: &ScheduledIntentBundle, - ) -> CommitPersistResult<()>; - fn set_commit_id( - &self, - message_id: u64, - pubkey: &Pubkey, - commit_id: u64, - ) -> CommitPersistResult<()>; - fn set_commit_strategy( - &self, - commit_id: u64, - pubkey: &Pubkey, - value: CommitStrategy, - ) -> CommitPersistResult<()>; - fn update_status_by_message( - &self, - message_id: u64, - pubkey: &Pubkey, - status: CommitStatus, - ) -> CommitPersistResult<()>; - fn update_status_by_commit( - &self, - commit_id: u64, - pubkey: &Pubkey, - status: CommitStatus, - ) -> CommitPersistResult<()>; - fn get_commit_statuses_by_message( - &self, - message_id: u64, - ) -> CommitPersistResult>; - fn pending_intent_bundles( - &self, - min_created_at: u64, - ) -> CommitPersistResult>; - fn get_commit_status_by_message( - &self, - message_id: u64, - pubkey: &Pubkey, - ) -> CommitPersistResult>; - fn get_signatures_by_commit( - &self, - commit_id: u64, - pubkey: &Pubkey, - ) -> CommitPersistResult>; - fn get_bundle_signatures( - &self, - message_id: u64, - ) -> CommitPersistResult>; - fn finalize_base_intent( - &self, - message_id: u64, - execution_output: ExecutionOutput, - ) -> CommitPersistResult<()>; -} - -#[derive(Clone)] -pub struct IntentPersisterImpl { - // DB that tracks lifespan of Commit intents - commits_db: Arc>, - // TODO(edwin): add something like - // actions_db: Arc> -} - -impl IntentPersisterImpl { - pub fn try_new

(db_file: P) -> CommitPersistResult - where - P: AsRef, - { - let db = CommittsDb::new(db_file)?; - db.create_commit_status_table()?; - db.create_bundle_signature_table()?; - - Ok(Self { - commits_db: Arc::new(Mutex::new(db)), - }) - } - - pub fn create_commit_rows( - intent_bundle: &ScheduledIntentBundle, - ) -> Vec { - let created_at = now(); - - let create_row = |undelegate: bool, account: &CommittedAccount| { - let data = &account.account.data; - let (commit_type, data) = if data.is_empty() { - (CommitType::EmptyAccount, None) - } else { - (CommitType::DataAccount, Some(data.clone())) - }; - - CommitStatusRow { - message_id: intent_bundle.id, - commit_id: 0, // Not known at creation, set later - pubkey: account.pubkey, - delegated_account_owner: account.account.owner, - slot: intent_bundle.slot, - ephemeral_blockhash: intent_bundle.blockhash, - undelegate, - lamports: account.account.lamports, - data, - commit_type, - created_at, - commit_strategy: CommitStrategy::default(), - commit_status: CommitStatus::Pending, - last_retried_at: created_at, - retries_count: 0, - } - }; - - [ - (false, intent_bundle.get_commit_intent_accounts()), - (true, intent_bundle.get_undelegate_intent_accounts()), - (false, intent_bundle.get_commit_finalize_intent_accounts()), - ( - true, - intent_bundle - .get_commit_finalize_and_undelegate_intent_accounts(), - ), - ] - .into_iter() - .filter_map(|(undelegate, accounts)| { - accounts.map(|accounts| (undelegate, accounts)) - }) - .flat_map(|(undelegate, accounts)| { - accounts - .iter() - .map(move |account| create_row(undelegate, account)) - }) - .collect() - } -} - -impl IntentPersister for IntentPersisterImpl { - fn start_base_intents( - &self, - base_intents: &[ScheduledIntentBundle], - ) -> CommitPersistResult<()> { - let commit_rows = base_intents - .iter() - .flat_map(Self::create_commit_rows) - .collect::>(); - // Insert all commit rows into the database - self.commits_db - .lock() - .expect(POISONED_MUTEX_MSG) - .insert_commit_status_rows(&commit_rows)?; - Ok(()) - } - - fn start_base_intent( - &self, - base_intents: &ScheduledIntentBundle, - ) -> CommitPersistResult<()> { - let commit_row = Self::create_commit_rows(base_intents); - self.commits_db - .lock() - .expect(POISONED_MUTEX_MSG) - .insert_commit_status_rows(&commit_row)?; - - Ok(()) - } - - fn set_commit_id( - &self, - message_id: u64, - pubkey: &Pubkey, - commit_id: u64, - ) -> CommitPersistResult<()> { - self.commits_db - .lock() - .expect(POISONED_MUTEX_MSG) - .set_commit_id(message_id, pubkey, commit_id) - } - - fn set_commit_strategy( - &self, - commit_id: u64, - pubkey: &Pubkey, - value: CommitStrategy, - ) -> CommitPersistResult<()> { - self.commits_db - .lock() - .expect(POISONED_MUTEX_MSG) - .set_commit_strategy(commit_id, pubkey, value) - } - - fn update_status_by_message( - &self, - message_id: u64, - pubkey: &Pubkey, - status: CommitStatus, - ) -> CommitPersistResult<()> { - self.commits_db - .lock() - .expect(POISONED_MUTEX_MSG) - .update_status_by_message(message_id, pubkey, &status) - } - - fn update_status_by_commit( - &self, - commit_id: u64, - pubkey: &Pubkey, - status: CommitStatus, - ) -> CommitPersistResult<()> { - self.commits_db - .lock() - .expect(POISONED_MUTEX_MSG) - .update_status_by_commit(commit_id, pubkey, &status) - } - - fn get_commit_statuses_by_message( - &self, - message_id: u64, - ) -> CommitPersistResult> { - self.commits_db - .lock() - .expect(POISONED_MUTEX_MSG) - .get_commit_statuses_by_id(message_id) - } - - /// Returns pending bundles created at or after `min_created_at`. - /// NOTE: this constructs `ScheduleIntentBundle` only from existing information. - /// As persister doesn't save `ScheduleIntentBundle::payer` info, Pubkey::default is used. - /// `CommittedAccount` information like slot may also be outdated. - /// It is the responsibility of the calling site to refresh data in intent. - fn pending_intent_bundles( - &self, - min_created_at: u64, - ) -> CommitPersistResult> { - let rows = self - .commits_db - .lock() - .expect(POISONED_MUTEX_MSG) - .get_pending_commit_statuses(min_created_at)?; - - Ok(pending_rows_to_scheduled_intent_bundles( - rows, - min_created_at, - )) - } - - fn get_commit_status_by_message( - &self, - message_id: u64, - pubkey: &Pubkey, - ) -> CommitPersistResult> { - self.commits_db - .lock() - .expect(POISONED_MUTEX_MSG) - .get_commit_status(message_id, pubkey) - } - - fn get_signatures_by_commit( - &self, - commit_id: u64, - pubkey: &Pubkey, - ) -> CommitPersistResult> { - self.commits_db - .lock() - .expect(POISONED_MUTEX_MSG) - .get_signatures_by_commit(commit_id, pubkey) - } - - fn get_bundle_signatures( - &self, - message_id: u64, - ) -> CommitPersistResult> { - self.commits_db - .lock() - .expect(POISONED_MUTEX_MSG) - .get_bundle_signature_by_bundle_id(message_id) - } - - fn finalize_base_intent( - &self, - message_id: u64, - execution_output: ExecutionOutput, - ) -> CommitPersistResult<()> { - let (commit_signature, finalize_signature) = match execution_output { - ExecutionOutput::SingleStage(signature) => (signature, signature), - ExecutionOutput::TwoStage { - commit_signature, - finalize_signature, - } => (commit_signature, finalize_signature), - }; - - let bundle_signature_row = BundleSignatureRow::new( - message_id, - commit_signature, - finalize_signature, - ); - let commits_db = self.commits_db.lock().expect(POISONED_MUTEX_MSG); - commits_db.insert_bundle_signature_row(&bundle_signature_row)?; - Ok(()) - } -} - -/// Blanket implementation for Option -impl IntentPersister for Option { - fn start_base_intents( - &self, - base_intents: &[ScheduledIntentBundle], - ) -> CommitPersistResult<()> { - match self { - Some(persister) => persister.start_base_intents(base_intents), - None => Ok(()), - } - } - - fn start_base_intent( - &self, - base_intents: &ScheduledIntentBundle, - ) -> CommitPersistResult<()> { - match self { - Some(persister) => persister.start_base_intent(base_intents), - None => Ok(()), - } - } - - fn set_commit_id( - &self, - message_id: u64, - pubkey: &Pubkey, - commit_id: u64, - ) -> CommitPersistResult<()> { - match self { - Some(persister) => { - persister.set_commit_id(message_id, pubkey, commit_id) - } - None => Ok(()), - } - } - - fn set_commit_strategy( - &self, - commit_id: u64, - pubkey: &Pubkey, - value: CommitStrategy, - ) -> CommitPersistResult<()> { - match self { - Some(persister) => { - persister.set_commit_strategy(commit_id, pubkey, value) - } - None => Ok(()), - } - } - - fn update_status_by_message( - &self, - message_id: u64, - pubkey: &Pubkey, - status: CommitStatus, - ) -> CommitPersistResult<()> { - match self { - Some(persister) => { - persister.update_status_by_message(message_id, pubkey, status) - } - None => Ok(()), - } - } - - fn update_status_by_commit( - &self, - commit_id: u64, - pubkey: &Pubkey, - status: CommitStatus, - ) -> CommitPersistResult<()> { - match self { - Some(persister) => { - persister.update_status_by_commit(commit_id, pubkey, status) - } - None => Ok(()), - } - } - - fn get_commit_statuses_by_message( - &self, - message_id: u64, - ) -> CommitPersistResult> { - match self { - Some(persister) => { - persister.get_commit_statuses_by_message(message_id) - } - None => Ok(Vec::new()), - } - } - - fn pending_intent_bundles( - &self, - min_created_at: u64, - ) -> CommitPersistResult> { - match self { - Some(persister) => persister.pending_intent_bundles(min_created_at), - None => Ok(Vec::new()), - } - } - - fn get_commit_status_by_message( - &self, - message_id: u64, - pubkey: &Pubkey, - ) -> CommitPersistResult> { - match self { - Some(persister) => { - persister.get_commit_status_by_message(message_id, pubkey) - } - None => Ok(None), - } - } - - fn get_signatures_by_commit( - &self, - commit_id: u64, - pubkey: &Pubkey, - ) -> CommitPersistResult> { - match self { - Some(persister) => { - persister.get_signatures_by_commit(commit_id, pubkey) - } - None => Ok(None), - } - } - - fn get_bundle_signatures( - &self, - message_id: u64, - ) -> CommitPersistResult> { - match self { - Some(persister) => persister.get_bundle_signatures(message_id), - None => Ok(None), - } - } - - fn finalize_base_intent( - &self, - message_id: u64, - execution_output: ExecutionOutput, - ) -> CommitPersistResult<()> { - match self { - Some(persister) => { - persister.finalize_base_intent(message_id, execution_output) - } - None => Ok(()), - } - } -} - -fn pending_rows_to_scheduled_intent_bundles( - rows: Vec, - min_created_at: u64, -) -> Vec { - let mut grouped_rows = BTreeMap::>::new(); - for row in rows { - grouped_rows.entry(row.message_id).or_default().push(row); - } - - grouped_rows - .into_iter() - // Filter bundles where any row is older than the recovery window - .filter(|(message_id, rows)| { - if rows.iter().any(|row| row.created_at < min_created_at) { - warn!( - intent_id = message_id, - "Skipping pending commit intent: too old to recover" - ); - false - } else { - true - } - }) - // Filter malformed items - .filter_map(|(message_id, rows)| { - let first = rows.first()?; - let slot = first.slot; - let blockhash = first.ephemeral_blockhash; - if rows.iter().any(|r| r.slot != slot || r.ephemeral_blockhash != blockhash) { - warn!( - intent_id = message_id, - "Skipping pending commit intent: rows disagree on slot or ephemeral_blockhash" - ); - None - } else { - Some(rows) - } - }) - .filter_map(intent_bundle_from_rows) - .collect() -} - -fn intent_bundle_from_rows( - rows: Vec, -) -> Option { - let first = rows.first()?; - let message_id = first.message_id; - let slot = first.slot; - let blockhash = first.ephemeral_blockhash; - - let mut commit_finalize_accounts = Vec::new(); - let mut commit_finalize_and_undelegate_accounts = Vec::new(); - for row in rows { - let Some((account, undelegate)) = committed_account_from_row(row) - else { - continue; - }; - if undelegate { - commit_finalize_and_undelegate_accounts.push(account); - } else { - commit_finalize_accounts.push(account); - } - } - - let mut intent_bundle = MagicIntentBundle::default(); - if !commit_finalize_accounts.is_empty() { - intent_bundle.commit_finalize = - Some(IntentCommitType::Standalone(commit_finalize_accounts)); - } - if !commit_finalize_and_undelegate_accounts.is_empty() { - intent_bundle.commit_finalize_and_undelegate = - Some(CommitAndUndelegate { - commit_action: IntentCommitType::Standalone( - commit_finalize_and_undelegate_accounts, - ), - undelegate_action: UndelegateType::Standalone, - }); - } - if intent_bundle.is_empty() { - return None; - } - - Some(ScheduledIntentBundle { - id: message_id, - slot, - blockhash, - sent_transaction: Transaction::default(), - payer: Pubkey::default(), - intent_bundle, - }) -} - -fn committed_account_from_row( - row: CommitStatusRow, -) -> Option<(CommittedAccount, bool)> { - if row.commit_type == CommitType::DataAccount && row.data.is_none() { - warn!( - intent_id = row.message_id, - pubkey = %row.pubkey, - "Skipping pending data-account commit row without account data" - ); - return None; - } - - Some(( - CommittedAccount { - pubkey: row.pubkey, - account: Account { - lamports: row.lamports, - data: row.data.unwrap_or_default(), - owner: row.delegated_account_owner, - executable: false, - rent_epoch: 0, - }, - remote_slot: 0, - }, - row.undelegate, - )) -} - -#[cfg(test)] -mod tests { - use magicblock_core::intent::CommittedAccount; - use magicblock_program::magic_scheduled_base_intent::{ - CommitAndUndelegate, CommitType, MagicIntentBundle, UndelegateType, - }; - use solana_account::Account; - use solana_hash::Hash; - use solana_pubkey::Pubkey; - use solana_signature::Signature; - use solana_transaction::Transaction; - use tempfile::NamedTempFile; - - use super::*; - use crate::{ - committor_processor::RECOVERY_MAX_AGE_SECS, - persist::{types, CommitStatusSignatures}, - test_utils, - }; - - fn create_test_persister() -> (IntentPersisterImpl, NamedTempFile) { - test_utils::init_test_logger(); - let temp_file = NamedTempFile::new().unwrap(); - let persister = IntentPersisterImpl::try_new(temp_file.path()).unwrap(); - (persister, temp_file) - } - - fn test_accounts() -> Vec { - let account1 = Account { - lamports: 1000, - owner: Pubkey::new_unique(), - data: vec![], - executable: false, - rent_epoch: 0, - }; - let account2 = Account { - lamports: 2000, - owner: Pubkey::new_unique(), - data: vec![1, 2, 3], - executable: false, - rent_epoch: 0, - }; - - vec![ - CommittedAccount { - pubkey: Pubkey::new_unique(), - account: account1, - remote_slot: 1, - }, - CommittedAccount { - pubkey: Pubkey::new_unique(), - account: account2, - remote_slot: 2, - }, - ] - } - - fn commit_only_bundle() -> MagicIntentBundle { - MagicIntentBundle { - commit: Some(CommitType::Standalone(test_accounts())), - commit_and_undelegate: None, - commit_finalize: None, - commit_finalize_and_undelegate: None, - standalone_actions: vec![], - } - } - - fn undelegate_only_bundle() -> MagicIntentBundle { - MagicIntentBundle { - commit: None, - commit_and_undelegate: Some(CommitAndUndelegate { - commit_action: CommitType::Standalone(test_accounts()), - undelegate_action: UndelegateType::Standalone, - }), - commit_finalize: None, - commit_finalize_and_undelegate: None, - standalone_actions: vec![], - } - } - - fn bundle_both() -> MagicIntentBundle { - MagicIntentBundle { - commit: Some(CommitType::Standalone(test_accounts())), - commit_and_undelegate: Some(CommitAndUndelegate { - commit_action: CommitType::Standalone(test_accounts()), - undelegate_action: UndelegateType::Standalone, - }), - commit_finalize: None, - commit_finalize_and_undelegate: None, - standalone_actions: vec![], - } - } - - fn bundle_none() -> MagicIntentBundle { - MagicIntentBundle { - commit: None, - commit_and_undelegate: None, - commit_finalize: None, - commit_finalize_and_undelegate: None, - standalone_actions: vec![], - } - } - - fn create_test_message( - id: u64, - intent_bundle: MagicIntentBundle, - ) -> ScheduledIntentBundle { - ScheduledIntentBundle { - id, - slot: 100, - blockhash: Hash::new_unique(), - sent_transaction: Transaction::default(), - payer: Pubkey::new_unique(), - intent_bundle, - } - } - - #[test] - fn test_create_commit_rows_commit_only() { - let message = create_test_message(1, commit_only_bundle()); - let rows = IntentPersisterImpl::create_commit_rows(&message); - - assert_eq!(rows.len(), 2); - assert!(rows.iter().all(|r| !r.undelegate)); - - let empty_account = rows.iter().find(|r| r.data.is_none()).unwrap(); - assert_eq!(empty_account.commit_type, types::CommitType::EmptyAccount); - assert_eq!(empty_account.lamports, 1000); - - let data_account = rows.iter().find(|r| r.data.is_some()).unwrap(); - assert_eq!(data_account.commit_type, types::CommitType::DataAccount); - assert_eq!(data_account.lamports, 2000); - assert_eq!(data_account.data, Some(vec![1, 2, 3])); - } - - #[test] - fn test_create_commit_rows_undelegate_only() { - let message = create_test_message(1, undelegate_only_bundle()); - let rows = IntentPersisterImpl::create_commit_rows(&message); - - assert_eq!(rows.len(), 2); - assert!(rows.iter().all(|r| r.undelegate)); - } - - #[test] - fn test_create_commit_rows_both_intents() { - let message = create_test_message(1, bundle_both()); - let rows = IntentPersisterImpl::create_commit_rows(&message); - - // 2 from commit + 2 from commit_and_undelegate - assert_eq!(rows.len(), 4); - - let commit_rows = rows.iter().filter(|r| !r.undelegate).count(); - let undelegate_rows = rows.iter().filter(|r| r.undelegate).count(); - - assert_eq!(commit_rows, 2); - assert_eq!(undelegate_rows, 2); - } - - #[test] - fn test_start_base_message_commit_only() { - let (persister, _temp_file) = create_test_persister(); - let message = create_test_message(1, commit_only_bundle()); - - persister.start_base_intent(&message).unwrap(); - - let expected_statuses = - IntentPersisterImpl::create_commit_rows(&message); - let statuses = persister.get_commit_statuses_by_message(1).unwrap(); - - assert_eq!(statuses.len(), expected_statuses.len()); - assert_eq!(expected_statuses[0], statuses[0]); - assert_eq!(expected_statuses[1], statuses[1]); - } - - #[test] - fn test_start_base_messages_mixed() { - let (persister, _temp_file) = create_test_persister(); - let message1 = create_test_message(1, commit_only_bundle()); // 2 rows - let message2 = create_test_message(2, undelegate_only_bundle()); // 2 rows - let message3 = create_test_message(3, bundle_both()); // 4 rows - let message4 = create_test_message(4, bundle_none()); // 0 rows - - persister - .start_base_intents(&[message1, message2, message3, message4]) - .unwrap(); - - assert_eq!( - persister.get_commit_statuses_by_message(1).unwrap().len(), - 2 - ); - assert_eq!( - persister.get_commit_statuses_by_message(2).unwrap().len(), - 2 - ); - assert_eq!( - persister.get_commit_statuses_by_message(3).unwrap().len(), - 4 - ); - assert_eq!( - persister.get_commit_statuses_by_message(4).unwrap().len(), - 0 - ); - } - - #[test] - fn test_update_status() { - let (persister, _temp_file) = create_test_persister(); - let message = create_test_message(1, commit_only_bundle()); - persister.start_base_intent(&message).unwrap(); - - let pubkey = message.get_all_committed_pubkeys()[0]; - - // Update by message - persister - .update_status_by_message(1, &pubkey, CommitStatus::Pending) - .unwrap(); - - let updated = persister - .get_commit_status_by_message(1, &pubkey) - .unwrap() - .unwrap(); - assert_eq!(updated.commit_status, CommitStatus::Pending); - - // Set commit ID and update by commit - persister.set_commit_id(1, &pubkey, 100).unwrap(); - persister - .update_status_by_commit( - 100, - &pubkey, - CommitStatus::BufferAndChunkInitialized, - ) - .unwrap(); - - let updated = persister - .get_commit_status_by_message(1, &pubkey) - .unwrap() - .unwrap(); - assert_eq!( - updated.commit_status, - CommitStatus::BufferAndChunkInitialized - ); - } - - #[test] - fn test_set_commit_strategy() { - let (persister, _temp_file) = create_test_persister(); - let message = create_test_message(1, commit_only_bundle()); - persister.start_base_intent(&message).unwrap(); - - let pubkey = message.get_all_committed_pubkeys()[0]; - persister.set_commit_id(1, &pubkey, 100).unwrap(); - - persister - .set_commit_strategy(100, &pubkey, CommitStrategy::StateArgs) - .unwrap(); - - let updated = persister - .get_commit_status_by_message(1, &pubkey) - .unwrap() - .unwrap(); - assert_eq!(updated.commit_strategy, CommitStrategy::StateArgs); - } - - #[test] - fn test_get_signatures() { - let (persister, _temp_file) = create_test_persister(); - let message = create_test_message(1, commit_only_bundle()); - persister.start_base_intent(&message).unwrap(); - - let statuses = persister.get_commit_statuses_by_message(1).unwrap(); - let pubkey = statuses[0].pubkey; - persister.set_commit_id(1, &pubkey, 100).unwrap(); - - let process_sig = Signature::new_unique(); - let finalize_sig = Signature::new_unique(); - let status = CommitStatus::Succeeded(CommitStatusSignatures { - commit_stage_signature: process_sig, - finalize_stage_signature: Some(finalize_sig), - }); - - persister - .update_status_by_commit(100, &pubkey, status) - .unwrap(); - - let sigs = persister - .get_signatures_by_commit(100, &pubkey) - .unwrap() - .unwrap(); - assert_eq!(sigs.commit_stage_signature, process_sig); - assert_eq!(sigs.finalize_stage_signature, Some(finalize_sig)); - } - - #[test] - fn test_finalize_base_intent() { - let (persister, _temp_file) = create_test_persister(); - let message_id = 1; - - let commit_sig = Signature::new_unique(); - let finalize_sig = Signature::new_unique(); - let execution_output = ExecutionOutput::TwoStage { - commit_signature: commit_sig, - finalize_signature: finalize_sig, - }; - - persister - .finalize_base_intent(message_id, execution_output) - .unwrap(); - - let bundle_sig = persister - .get_bundle_signatures(message_id) - .unwrap() - .unwrap(); - assert_eq!(bundle_sig.bundle_id, message_id); - assert_eq!(bundle_sig.commit_stage_signature, commit_sig); - assert_eq!(bundle_sig.finalize_stage_signature, finalize_sig); - } - - #[test] - fn test_empty_accounts_not_persisted() { - let (persister, _temp_file) = create_test_persister(); - let message = create_test_message(1, bundle_none()); - - persister.start_base_intent(&message).unwrap(); - - let statuses = persister.get_commit_statuses_by_message(1).unwrap(); - assert_eq!(statuses.len(), 0); - } - - fn pending_row( - message_id: u64, - pubkey: Pubkey, - owner: Pubkey, - blockhash: Hash, - undelegate: bool, - data: Option>, - ) -> CommitStatusRow { - let commit_type = - if data.as_ref().map(|data| data.is_empty()) == Some(false) { - types::CommitType::DataAccount - } else { - types::CommitType::EmptyAccount - }; - CommitStatusRow { - message_id, - pubkey, - commit_id: 0, - delegated_account_owner: owner, - slot: 42, - ephemeral_blockhash: blockhash, - undelegate, - lamports: 1_000, - data, - commit_type, - created_at: 1, - commit_status: CommitStatus::Pending, - commit_strategy: Default::default(), - last_retried_at: 1, - retries_count: 0, - } - } - - #[test] - fn pending_rows_reconstruct_commit_finalize_bundle() { - let owner = Pubkey::new_unique(); - let blockhash = Hash::new_unique(); - let commit_pubkey = Pubkey::new_unique(); - let undelegate_pubkey = Pubkey::new_unique(); - - // default rows have created_at=1; use min_created_at=0 so nothing is filtered - let bundles = pending_rows_to_scheduled_intent_bundles( - vec![ - pending_row( - 9, - commit_pubkey, - owner, - blockhash, - false, - Some(vec![1, 2, 3]), - ), - pending_row(9, undelegate_pubkey, owner, blockhash, true, None), - ], - 0, - ); - - assert_eq!(bundles.len(), 1); - let bundle = &bundles[0]; - assert_eq!(bundle.id, 9); - assert_eq!(bundle.slot, 42); - assert_eq!(bundle.blockhash, blockhash); - - let commit_accounts = - bundle.get_commit_finalize_intent_accounts().unwrap(); - assert_eq!(commit_accounts.len(), 1); - assert_eq!(commit_accounts[0].pubkey, commit_pubkey); - assert_eq!(commit_accounts[0].account.data, vec![1, 2, 3]); - assert_eq!(commit_accounts[0].remote_slot, 0); - - let undelegate_accounts = bundle - .get_commit_finalize_and_undelegate_intent_accounts() - .unwrap(); - assert_eq!(undelegate_accounts.len(), 1); - assert_eq!(undelegate_accounts[0].pubkey, undelegate_pubkey); - assert_eq!(undelegate_accounts[0].account.owner, owner); - assert!(bundle.has_undelegate_intent()); - } - - #[test] - fn pending_rows_skip_intents_older_than_recovery_window() { - let owner = Pubkey::new_unique(); - let blockhash = Hash::new_unique(); - let recovery_time = RECOVERY_MAX_AGE_SECS + 1; - let min_created_at = recovery_time - RECOVERY_MAX_AGE_SECS; // = 1 - - // At the boundary → included - let mut in_window = pending_row( - 1, - Pubkey::new_unique(), - owner, - blockhash, - false, - Some(vec![1]), - ); - in_window.created_at = min_created_at; - let included = pending_rows_to_scheduled_intent_bundles( - vec![in_window], - min_created_at, - ); - assert_eq!(included.len(), 1); - assert_eq!(included[0].id, 1); - - // Recent (just created) → included - let mut recent = pending_row( - 2, - Pubkey::new_unique(), - owner, - blockhash, - false, - Some(vec![1]), - ); - recent.created_at = recovery_time; - let recent_result = pending_rows_to_scheduled_intent_bundles( - vec![recent], - min_created_at, - ); - assert_eq!(recent_result.len(), 1); - - // Too old → excluded - let mut too_old = pending_row( - 3, - Pubkey::new_unique(), - owner, - blockhash, - false, - Some(vec![1]), - ); - too_old.created_at = min_created_at - 1; // = 0 - let excluded_old = pending_rows_to_scheduled_intent_bundles( - vec![too_old], - min_created_at, - ); - assert!(excluded_old.is_empty()); - } -} diff --git a/magicblock-committor-service/src/persist/db.rs b/magicblock-committor-service/src/persist/db.rs deleted file mode 100644 index 314c627ff..000000000 --- a/magicblock-committor-service/src/persist/db.rs +++ /dev/null @@ -1,1216 +0,0 @@ -use std::{fmt, path::Path, str::FromStr}; - -use rusqlite::{params, Connection, Result, Transaction}; -use solana_hash::Hash; -use solana_program::clock::Slot; -use solana_pubkey::Pubkey; -use solana_signature::Signature; -use tracing::error; - -use super::{ - error::CommitPersistResult, - utils::{i64_into_u64, u64_into_i64}, - CommitStatus, CommitStatusSignatures, CommitStrategy, CommitType, -}; -use crate::persist::{error::CommitPersistError, utils::now}; - -// ----------------- -// CommitStatusRow -// ----------------- -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommitStatusRow { - /// ID of the messages within which this account is committed - pub message_id: u64, - /// The on chain address of the delegated account - pub pubkey: Pubkey, - /// Commit ID of an account - /// Determined and set during runtime - pub commit_id: u64, - /// The original owner of the delegated account on chain - pub delegated_account_owner: Pubkey, - /// The ephemeral slot at which those changes were requested - pub slot: Slot, - /// The ephemeral blockhash at which those changes were requested - pub ephemeral_blockhash: Hash, - /// If we also undelegate the account after committing it - pub undelegate: bool, - /// Lamports of the account in the ephemeral - pub lamports: u64, - /// The account data in the ephemeral (only set if the commit is for a data account) - pub data: Option>, - /// The type of commit that was requested, i.e. lamports only or including data - pub commit_type: CommitType, - /// Time since epoch at which the commit was requested - pub created_at: u64, - /// The current status of the commit - /// Includes the bundle_id which will be the same for accounts whose commits - /// need to be applied atomically in a single transaction - /// For single accounts a bundle_id will be generated as well for consistency - /// For Pending commits the bundle_id is not set - pub commit_status: CommitStatus, - /// Strategy defined for Commit of a particular account - pub commit_strategy: CommitStrategy, - /// Time since epoch at which the commit was last retried - pub last_retried_at: u64, - /// Number of times the commit was retried - pub retries_count: u16, -} - -#[derive(Debug)] -pub struct MessageSignatures { - /// The signature of the transaction on chain that executed Commit Stage - pub commit_stage_signature: Signature, - /// The signature of the transaction on chain that executed Finalize Stage - /// if they were executed in 1 tx it is the same as [Self::commit_stage_signature]. - pub finalize_stage_signature: Option, - /// Time since epoch at which the bundle signature was created - pub created_at: u64, -} - -impl fmt::Display for CommitStatusRow { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "CommitStatusRow {{ - message_id: {} - pubkey: {}, - commit_id: {}, - delegated_account_owner: {}, - slot: {}, - ephemeral_blockhash: {}, - undelegate: {}, - lamports: {}, - data.len: {}, - commit_type: {}, - created_at: {}, - commit_status: {}, - commit_strategy: {}, - last_retried_at: {}, - retries_count: {} -}}", - self.message_id, - self.pubkey, - self.commit_id, - self.delegated_account_owner, - self.slot, - self.ephemeral_blockhash, - self.undelegate, - self.lamports, - self.data.as_ref().map(|x| x.len()).unwrap_or_default(), - self.commit_type.as_str(), - self.created_at, - self.commit_status, - self.commit_strategy.as_str(), - self.last_retried_at, - self.retries_count - ) - } -} - -const ALL_COMMIT_STATUS_COLUMNS: &str = " - message_id, - pubkey, - commit_id, - delegated_account_owner, - slot, - ephemeral_blockhash, - undelegate, - lamports, - data, - commit_type, - created_at, - commit_strategy, - commit_status, - commit_stage_signature, - finalize_stage_signature, - last_retried_at, - retries_count -"; - -const SELECT_ALL_COMMIT_STATUS_COLUMNS: &str = r#" -SELECT - message_id, - pubkey, - commit_id, - delegated_account_owner, - slot, - ephemeral_blockhash, - undelegate, - lamports, - data, - commit_type, - created_at, - commit_strategy, - commit_status, - commit_stage_signature, - finalize_stage_signature, - last_retried_at, - retries_count -FROM commit_status -"#; - -// ----------------- -// Bundle Signature -// ----------------- -// The BundleSignature table exists to store mappings from bundle_id to the signatures used -// to commit/finalize these bundles. -// The signatures are repeated in the commit_status table, however the rows in there have a -// different lifetime than the bundle signature rows. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BundleSignatureRow { - /// The id of the bundle that was commited - /// If an account was not part of a bundle it is treated as a single account bundle - /// for consistency. - /// The bundle_id is unique - pub bundle_id: u64, - /// The signature of the transaction that executed Commit stage - pub commit_stage_signature: Signature, - /// The signature of the transaction that executed Finalize stage - /// if Commit & Finalize stages this will equal [Self::commit_stage_signature] - pub finalize_stage_signature: Signature, - /// Time since epoch at which the bundle signature was created - pub created_at: u64, -} - -impl BundleSignatureRow { - pub fn new( - bundle_id: u64, - commit_stage_signature: Signature, - finalize_stage_signature: Signature, - ) -> Self { - let created_at = now(); - Self { - bundle_id, - commit_stage_signature, - finalize_stage_signature, - created_at, - } - } -} - -const ALL_BUNDLE_SIGNATURE_COLUMNS: &str = r#" - bundle_id, - commit_stage_signature, - finalize_stage_signature, - created_at -"#; - -const SELECT_ALL_BUNDLE_SIGNATURE_COLUMNS: &str = r#" -SELECT - bundle_id, - commit_stage_signature, - finalize_stage_signature, - created_at -FROM bundle_signature -"#; - -// ----------------- -// CommittorDb -// ----------------- -pub struct CommittsDb { - conn: Connection, -} - -impl CommittsDb { - pub fn new

(db_file: P) -> Result - where - P: AsRef, - { - let conn = Connection::open(db_file)?; - Ok(Self { conn }) - } - - pub fn path(&self) -> Option<&str> { - self.conn.path() - } - - // ----------------- - // Methods affecting both tables - // ----------------- - pub fn update_status_by_message( - &mut self, - message_id: u64, - pubkey: &Pubkey, - status: &CommitStatus, - ) -> CommitPersistResult<()> { - let query = "UPDATE commit_status - SET - commit_status = ?1, - commit_stage_signature = ?2, - finalize_stage_signature = ?3 - WHERE - pubkey = ?4 AND message_id = ?5"; - - self.conn.execute( - query, - params![ - status.as_str(), - status - .signatures() - .map(|s| s.commit_stage_signature.to_string()), - status - .signatures() - .and_then(|s| s.finalize_stage_signature) - .map(|s| s.to_string()), - pubkey.to_string(), - u64_into_i64(message_id) - ], - )?; - - Ok(()) - } - - pub fn update_status_by_commit( - &mut self, - commit_id: u64, - pubkey: &Pubkey, - status: &CommitStatus, - ) -> CommitPersistResult<()> { - let query = "UPDATE commit_status - SET - commit_status = ?1, - commit_stage_signature = ?2, - finalize_stage_signature = ?3 - WHERE - pubkey = ?4 AND commit_id = ?5"; - - self.conn.execute( - query, - params![ - status.as_str(), - status - .signatures() - .map(|s| s.commit_stage_signature.to_string()), - status - .signatures() - .and_then(|s| s.finalize_stage_signature) - .map(|s| s.to_string()), - pubkey.to_string(), - u64_into_i64(commit_id) - ], - )?; - - Ok(()) - } - - pub fn set_commit_strategy( - &mut self, - commit_id: u64, - pubkey: &Pubkey, - value: CommitStrategy, - ) -> CommitPersistResult<()> { - let query = "UPDATE commit_status - SET - commit_strategy = ?1 - WHERE - pubkey = ?2 AND commit_id = ?3"; - - self.conn.execute( - query, - params![ - value.as_str(), - pubkey.to_string(), - u64_into_i64(commit_id) - ], - )?; - - Ok(()) - } - - pub fn set_commit_id( - &mut self, - message_id: u64, - pubkey: &Pubkey, - commit_id: u64, - ) -> CommitPersistResult<()> { - let query = "UPDATE commit_status - SET - commit_id = ?1 - WHERE - pubkey = ?2 AND message_id = ?3"; - - self.conn.execute( - query, - params![ - u64_into_i64(commit_id), - pubkey.to_string(), - u64_into_i64(message_id) - ], - )?; - - Ok(()) - } - - // ----------------- - // Commit Status - // ----------------- - pub fn create_commit_status_table(&self) -> Result<()> { - match self.conn.execute_batch( - " - BEGIN; - CREATE TABLE IF NOT EXISTS commit_status ( - message_id INTEGER NOT NULL, - pubkey TEXT NOT NULL, - commit_id INTEGER NOT NULL, - delegated_account_owner TEXT NOT NULL, - slot INTEGER NOT NULL, - ephemeral_blockhash TEXT NOT NULL, - undelegate INTEGER NOT NULL, - lamports INTEGER NOT NULL, - data BLOB, - commit_type TEXT NOT NULL, - created_at INTEGER NOT NULL, - commit_strategy TEXT NOT NULL, - commit_status TEXT NOT NULL, - commit_stage_signature TEXT, - finalize_stage_signature TEXT, - last_retried_at INTEGER NOT NULL, - retries_count INTEGER NOT NULL, - PRIMARY KEY (message_id, commit_id, pubkey) - ); - CREATE INDEX IF NOT EXISTS idx_commits_pubkey ON commit_status (pubkey); - CREATE INDEX IF NOT EXISTS idx_commits_message_id ON commit_status (message_id); - CREATE INDEX IF NOT EXISTS idx_commits_commit_id ON commit_status (commit_id); - COMMIT;", - ) { - Ok(_) => Ok(()), - Err(err) => { - error!(error = ?err, "Failed to create commit_status table"); - Err(err) - } - } - } - - // ----------------- - // Bundle Signature - // ----------------- - pub fn create_bundle_signature_table(&self) -> Result<()> { - match self.conn.execute_batch( - " - BEGIN; - CREATE TABLE IF NOT EXISTS bundle_signature ( - bundle_id INTEGER NOT NULL PRIMARY KEY, - commit_stage_signature TEXT NOT NULL, - finalize_stage_signature TEXT, - created_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_bundle_signature ON bundle_signature (bundle_id); - COMMIT;", - ) { - Ok(_) => Ok(()), - Err(err) => { - error!(error = ?err, "Failed to create bundle_signature table"); - Err(err) - } - } - } - - pub fn insert_bundle_signature_row( - &self, - bundle_signature: &BundleSignatureRow, - ) -> CommitPersistResult<()> { - let query = format!("INSERT OR REPLACE INTO bundle_signature ({ALL_BUNDLE_SIGNATURE_COLUMNS}) - VALUES (?1, ?2, ?3, ?4)"); - - self.conn.execute( - &query, - params![ - bundle_signature.bundle_id, - bundle_signature.commit_stage_signature.to_string(), - bundle_signature.finalize_stage_signature.to_string(), - u64_into_i64(bundle_signature.created_at) - ], - )?; - - Ok(()) - } - - pub fn get_bundle_signature_by_bundle_id( - &self, - bundle_id: u64, - ) -> CommitPersistResult> { - let query = format!( - "{SELECT_ALL_BUNDLE_SIGNATURE_COLUMNS} WHERE bundle_id = ?1" - ); - let stmt = &mut self.conn.prepare(&query)?; - let mut rows = stmt.query(params![bundle_id])?; - - if let Some(row) = rows.next()? { - let bundle_signature_row = Self::extract_bundle_signature_row(row)?; - Ok(Some(bundle_signature_row)) - } else { - Ok(None) - } - } - - fn extract_bundle_signature_row( - row: &rusqlite::Row, - ) -> CommitPersistResult { - let bundle_id: u64 = { - let bundle_id: i64 = row.get(0)?; - i64_into_u64(bundle_id) - }; - let commit_stage_signature = { - let processed_signature: String = row.get(1)?; - Signature::from_str(processed_signature.as_str())? - }; - let finalize_stage_signature = { - let finalized_signature: String = row.get(2)?; - Signature::from_str(finalized_signature.as_str())? - }; - let created_at: u64 = { - let created_at: i64 = row.get(3)?; - i64_into_u64(created_at) - }; - - Ok(BundleSignatureRow { - bundle_id, - commit_stage_signature, - finalize_stage_signature, - created_at, - }) - } - - pub fn insert_commit_status_rows( - &mut self, - commit_rows: &[CommitStatusRow], - ) -> CommitPersistResult<()> { - if commit_rows.is_empty() { - return Ok(()); - } - - let tx = self.conn.transaction()?; - for commit in commit_rows { - Self::insert_commit_status_row(&tx, commit)?; - } - tx.commit()?; - Ok(()) - } - - fn insert_commit_status_row( - tx: &Transaction<'_>, - commit: &CommitStatusRow, - ) -> CommitPersistResult<()> { - let (commit_stage_signature, finalize_stage_signature) = - match commit.commit_status.signatures() { - Some(sigs) => ( - Some(sigs.commit_stage_signature), - sigs.finalize_stage_signature, - ), - None => (None, None), - }; - tx.execute( - &format!( - "INSERT INTO commit_status ({ALL_COMMIT_STATUS_COLUMNS}) VALUES - (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)", - ), - params![ - u64_into_i64(commit.message_id), - commit.pubkey.to_string(), - u64_into_i64(commit.commit_id), - commit.delegated_account_owner.to_string(), - u64_into_i64(commit.slot), - commit.ephemeral_blockhash.to_string(), - if commit.undelegate { 1 } else { 0 }, - u64_into_i64(commit.lamports), - commit.data.as_deref(), - commit.commit_type.as_str(), - u64_into_i64(commit.created_at), - commit.commit_strategy.as_str(), - commit.commit_status.as_str(), - commit_stage_signature - .as_ref() - .map(|s| s.to_string()), - finalize_stage_signature - .as_ref() - .map(|s| s.to_string()), - u64_into_i64(commit.last_retried_at), - commit.retries_count, - ], - )?; - Ok(()) - } - - #[cfg(test)] - #[allow(dead_code)] - fn get_commit_statuses_by_pubkey( - &self, - pubkey: &Pubkey, - ) -> CommitPersistResult> { - let query = - format!("{SELECT_ALL_COMMIT_STATUS_COLUMNS} WHERE pubkey = ?1"); - let stmt = &mut self.conn.prepare(&query)?; - let mut rows = stmt.query(params![pubkey.to_string()])?; - - extract_committor_rows(&mut rows) - } - - pub(crate) fn get_commit_statuses_by_id( - &self, - message_id: u64, - ) -> CommitPersistResult> { - let query = - format!("{SELECT_ALL_COMMIT_STATUS_COLUMNS} WHERE message_id = ?1"); - let stmt = &mut self.conn.prepare(&query)?; - let mut rows = stmt.query(params![u64_into_i64(message_id)])?; - - extract_committor_rows(&mut rows) - } - - pub(crate) fn get_pending_commit_statuses( - &self, - min_created_at: u64, - ) -> CommitPersistResult> { - let query = format!( - "WITH eligible_messages AS ( - SELECT message_id - FROM commit_status - WHERE commit_status = ?1 - GROUP BY message_id - HAVING MIN(created_at) >= ?2 - ) - {SELECT_ALL_COMMIT_STATUS_COLUMNS} - WHERE commit_status = ?1 - AND message_id IN ( - SELECT message_id FROM eligible_messages - ) - ORDER BY message_id, created_at, pubkey" - ); - let stmt = &mut self.conn.prepare(&query)?; - let mut rows = stmt.query(params![ - CommitStatus::Pending.as_str(), - u64_into_i64(min_created_at) - ])?; - - extract_committor_rows(&mut rows) - } - - pub(crate) fn get_commit_status( - &self, - message_id: u64, - pubkey: &Pubkey, - ) -> CommitPersistResult> { - let query = format!( - "{SELECT_ALL_COMMIT_STATUS_COLUMNS} WHERE message_id = ?1 AND pubkey = ?2" - ); - let stmt = &mut self.conn.prepare(&query)?; - let mut rows = - stmt.query(params![u64_into_i64(message_id), pubkey.to_string()])?; - - extract_committor_rows(&mut rows).map(|mut rows| rows.pop()) - } - - pub fn remove_commit_statuses_with_id( - &self, - message_id: u64, - ) -> CommitPersistResult<()> { - let query = "DELETE FROM commit_status WHERE message_id = ?1"; - let stmt = &mut self.conn.prepare(query)?; - stmt.execute(params![u64_into_i64(message_id)])?; - Ok(()) - } - - pub fn get_signatures_by_commit( - &self, - commit_id: u64, - pubkey: &Pubkey, - ) -> CommitPersistResult> { - let query = " - SELECT - commit_stage_signature, finalize_stage_signature, created_at - FROM commit_status - WHERE commit_id = ?1 AND pubkey = ?2 - LIMIT 1"; - - let mut stmt = self.conn.prepare(query)?; - let mut rows = - stmt.query(params![u64_into_i64(commit_id), pubkey.to_string()])?; - - let result = rows - .next()? - .map(|row| { - let commit_stage_signature: String = row.get(0)?; - let finalize_stage_signature: Option = row.get(1)?; - let created_at: i64 = row.get(2)?; - - Ok::<_, CommitPersistError>(MessageSignatures { - commit_stage_signature: Signature::from_str( - &commit_stage_signature, - )?, - finalize_stage_signature: finalize_stage_signature - .map(|s| Signature::from_str(&s)) - .transpose()?, - created_at: i64_into_u64(created_at), - }) - }) - .transpose()?; - - Ok(result) - } -} - -// ----------------- -// Commit Status Helpers -// ----------------- -fn extract_committor_rows( - rows: &mut rusqlite::Rows, -) -> CommitPersistResult> { - let mut commits = Vec::new(); - while let Some(row) = rows.next()? { - let commit_row = extract_committor_row(row)?; - commits.push(commit_row); - } - Ok(commits) -} - -fn extract_committor_row( - row: &rusqlite::Row, -) -> CommitPersistResult { - let message_id: u64 = { - let message_id: i64 = row.get(0)?; - i64_into_u64(message_id) - }; - - let pubkey = { - let pubkey: String = row.get(1)?; - Pubkey::try_from(pubkey.as_str())? - }; - let commit_id = { - let message_id: i64 = row.get(2)?; - i64_into_u64(message_id) - }; - let delegated_account_owner = { - let delegated_account_owner: String = row.get(3)?; - Pubkey::try_from(delegated_account_owner.as_str())? - }; - let slot: Slot = { - let slot: i64 = row.get(4)?; - i64_into_u64(slot) - }; - - let ephemeral_blockhash = { - let ephemeral_blockhash: String = row.get(5)?; - Hash::from_str(ephemeral_blockhash.as_str())? - }; - - let undelegate: bool = { - let undelegate: u8 = row.get(6)?; - undelegate == 1 - }; - - let lamports: u64 = { - let lamports: i64 = row.get(7)?; - i64_into_u64(lamports) - }; - - let data: Option> = row.get(8)?; - - let commit_type = { - let commit_type: String = row.get(9)?; - CommitType::try_from(commit_type.as_str())? - }; - let created_at: u64 = { - let created_at: i64 = row.get(10)?; - i64_into_u64(created_at) - }; - - let commit_strategy = { - let commit_strategy: String = row.get(11)?; - CommitStrategy::try_from(commit_strategy.as_str())? - }; - - let commit_status = { - let commit_status: String = row.get(12)?; - let commit_stage_signature = { - let commit_stage_signature: Option = row.get(13)?; - commit_stage_signature - .map(|s| Signature::from_str(s.as_str())) - .transpose()? - }; - let finalize_stage_signature = { - let finalize_stage_signature: Option = row.get(14)?; - finalize_stage_signature - .map(|s| Signature::from_str(s.as_str())) - .transpose()? - }; - let sigs = commit_stage_signature.map(|s| CommitStatusSignatures { - commit_stage_signature: s, - finalize_stage_signature, - }); - CommitStatus::try_from((commit_status.as_str(), sigs))? - }; - - let last_retried_at: u64 = { - let last_retried_at: i64 = row.get(15)?; - i64_into_u64(last_retried_at) - }; - let retries_count: u16 = { - let retries_count: i64 = row.get(16)?; - retries_count.try_into().unwrap_or_default() - }; - - Ok(CommitStatusRow { - message_id, - pubkey, - commit_id, - delegated_account_owner, - slot, - ephemeral_blockhash, - undelegate, - lamports, - data, - commit_type, - created_at, - commit_strategy, - commit_status, - last_retried_at, - retries_count, - }) -} - -#[cfg(test)] -mod tests { - use solana_hash::Hash; - use solana_signature::Signature; - use tempfile::NamedTempFile; - - use super::*; - use crate::test_utils; - - // Helper to create a test database - fn setup_test_db() -> (CommittsDb, NamedTempFile) { - test_utils::init_test_logger(); - let temp_file = NamedTempFile::new().unwrap(); - let db = CommittsDb::new(temp_file.path()).unwrap(); - db.create_commit_status_table().unwrap(); - db.create_bundle_signature_table().unwrap(); - - (db, temp_file) - } - - // Helper to create a test CommitStatusRow - fn create_test_row(message_id: u64, commit_id: u64) -> CommitStatusRow { - CommitStatusRow { - message_id, - commit_id, - pubkey: Pubkey::new_unique(), - delegated_account_owner: Pubkey::new_unique(), - slot: 100, - ephemeral_blockhash: Hash::new_unique(), - undelegate: false, - lamports: 1000, - data: Some(vec![1, 2, 3]), - commit_type: CommitType::DataAccount, - created_at: 1000, - commit_status: CommitStatus::Pending, - commit_strategy: CommitStrategy::StateArgs, - last_retried_at: 1000, - retries_count: 0, - } - } - - #[test] - fn test_table_creation() { - let (db, _) = setup_test_db(); - - // Verify table exists - let table_exists: bool = db.conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='commit_status')", - [], - |row| row.get(0), - ).unwrap(); - assert!(table_exists); - } - - #[test] - fn test_bundle_signature_table_creation() { - let (db, _) = setup_test_db(); - - // Verify bundle_signature table exists - let table_exists: bool = db.conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='bundle_signature')", - [], - |row| row.get(0), - ).unwrap(); - assert!(table_exists); - } - - #[test] - fn test_insert_and_retrieve_rows() { - let (mut db, _file) = setup_test_db(); - let row1 = create_test_row(1, 0); - let row2 = create_test_row(1, 0); // Same message_id, different pubkey - - // Insert rows - db.insert_commit_status_rows(&[row1.clone(), row2.clone()]) - .unwrap(); - - // Retrieve by message_id - let rows = db.get_commit_statuses_by_id(1).unwrap(); - assert_eq!(rows.len(), 2); - assert!(rows.contains(&row1)); - assert!(rows.contains(&row2)); - - // Retrieve individual row - let retrieved = db.get_commit_status(1, &row1.pubkey).unwrap().unwrap(); - assert_eq!(retrieved, row1); - } - - #[test] - fn test_get_pending_commit_statuses() { - let (mut db, _file) = setup_test_db(); - let pending = create_test_row(1, 0); - let mut succeeded = create_test_row(2, 0); - succeeded.commit_status = - CommitStatus::Succeeded(CommitStatusSignatures { - commit_stage_signature: Signature::new_unique(), - finalize_stage_signature: Some(Signature::new_unique()), - }); - - db.insert_commit_status_rows(&[pending.clone(), succeeded]) - .unwrap(); - - let rows = db.get_pending_commit_statuses(0).unwrap(); - assert_eq!(rows, vec![pending]); - } - - #[test] - fn test_get_pending_commit_statuses_filters_recovery_window() { - let (mut db, _file) = setup_test_db(); - let mut pending = create_test_row(1, 0); - pending.created_at = 10; - pending.last_retried_at = 19; - let mut pending_same_message = create_test_row(1, 0); - pending_same_message.created_at = 11; - pending_same_message.last_retried_at = 18; - let mut too_old = create_test_row(2, 0); - too_old.created_at = 9; - too_old.last_retried_at = 9; - let mut too_recent = create_test_row(3, 0); - too_recent.created_at = 20; - too_recent.last_retried_at = 20; - let mut partially_eligible = create_test_row(4, 0); - partially_eligible.created_at = 10; - partially_eligible.last_retried_at = 19; - let mut partially_too_recent = create_test_row(4, 0); - partially_too_recent.created_at = 10; - partially_too_recent.last_retried_at = 20; - - db.insert_commit_status_rows(&[ - pending.clone(), - pending_same_message.clone(), - too_old, - too_recent.clone(), - partially_eligible.clone(), - partially_too_recent.clone(), - ]) - .unwrap(); - - let rows = db.get_pending_commit_statuses(10).unwrap(); - assert_eq!( - rows, - vec![ - pending, - pending_same_message, - too_recent, - partially_eligible, - partially_too_recent - ] - ); - } - - #[test] - fn test_insert_and_retrieve_bundle_signature() { - let (db, _file) = setup_test_db(); - let bundle_id = 123; - let commit_sig = Signature::new_unique(); - let finalize_sig = Signature::new_unique(); - - let bundle_row = - BundleSignatureRow::new(bundle_id, commit_sig, finalize_sig); - db.insert_bundle_signature_row(&bundle_row).unwrap(); - - let retrieved = db - .get_bundle_signature_by_bundle_id(bundle_id) - .unwrap() - .unwrap(); - assert_eq!(retrieved.bundle_id, bundle_id); - assert_eq!(retrieved.commit_stage_signature, commit_sig); - assert_eq!(retrieved.finalize_stage_signature, finalize_sig); - } - - #[test] - fn test_get_nonexistent_bundle_signature() { - let (db, _file) = setup_test_db(); - let result = db.get_bundle_signature_by_bundle_id(999).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn test_set_commit_id() { - let (mut db, _file) = setup_test_db(); - let row = create_test_row(1, 0); - db.insert_commit_status_rows(std::slice::from_ref(&row)) - .unwrap(); - - // Update commit_id - db.set_commit_id(1, &row.pubkey, 100).unwrap(); - - // Verify update - let updated = db.get_commit_status(1, &row.pubkey).unwrap().unwrap(); - assert_eq!(updated.commit_id, 100); - } - - #[test] - fn test_update_status_by_message() { - let (mut db, _file) = setup_test_db(); - let row = create_test_row(1, 0); - db.insert_commit_status_rows(std::slice::from_ref(&row)) - .unwrap(); - - let new_status = CommitStatus::Pending; - db.update_status_by_message(1, &row.pubkey, &new_status) - .unwrap(); - - let updated = db.get_commit_status(1, &row.pubkey).unwrap().unwrap(); - assert_eq!(updated.commit_status, new_status); - } - - #[test] - fn test_update_status_by_commit() { - let (mut db, _file) = setup_test_db(); - let row = create_test_row(1, 100); // Set commit_id to 100 - db.insert_commit_status_rows(std::slice::from_ref(&row)) - .unwrap(); - - let new_status = CommitStatus::Succeeded(CommitStatusSignatures { - commit_stage_signature: Signature::new_unique(), - finalize_stage_signature: None, - }); - db.update_status_by_commit(100, &row.pubkey, &new_status) - .unwrap(); - - let updated = db.get_commit_status(1, &row.pubkey).unwrap().unwrap(); - assert_eq!(updated.commit_status, new_status); - } - - #[test] - fn test_set_commit_strategy() { - let (mut db, _file) = setup_test_db(); - let row = create_test_row(1, 100); - db.insert_commit_status_rows(std::slice::from_ref(&row)) - .unwrap(); - - let new_strategy = CommitStrategy::StateBuffer; - db.set_commit_strategy(100, &row.pubkey, new_strategy) - .unwrap(); - - let updated = db.get_commit_status(1, &row.pubkey).unwrap().unwrap(); - assert_eq!(updated.commit_strategy, new_strategy); - } - - #[test] - fn test_get_signatures_by_commit() { - let (mut db, _file) = setup_test_db(); - let commit_stage_signature = Signature::new_unique(); - let finalize_sig = Signature::new_unique(); - - let mut row = create_test_row(1, 100); - row.commit_status = CommitStatus::Succeeded(CommitStatusSignatures { - commit_stage_signature, - finalize_stage_signature: Some(finalize_sig), - }); - db.insert_commit_status_rows(&[row.clone()]).unwrap(); - - let sigs = db - .get_signatures_by_commit(100, &row.pubkey) - .unwrap() - .unwrap(); - assert_eq!(sigs.commit_stage_signature, commit_stage_signature); - assert_eq!(sigs.finalize_stage_signature, Some(finalize_sig)); - } - - #[test] - fn test_remove_commit_statuses() { - let (mut db, _file) = setup_test_db(); - let row1 = create_test_row(1, 0); - let row2 = create_test_row(2, 0); - db.insert_commit_status_rows(&[row1.clone(), row2.clone()]) - .unwrap(); - - // Remove one message - db.remove_commit_statuses_with_id(1).unwrap(); - - // Verify removal - assert!(db.get_commit_statuses_by_id(1).unwrap().is_empty()); - assert_eq!(db.get_commit_statuses_by_id(2).unwrap().len(), 1); - } - - #[test] - fn test_empty_data_handling() { - let (mut db, _file) = setup_test_db(); - let mut row = create_test_row(1, 0); - row.data = None; - row.commit_type = CommitType::EmptyAccount; - - db.insert_commit_status_rows(&[row.clone()]).unwrap(); - - let retrieved = db.get_commit_status(1, &row.pubkey).unwrap().unwrap(); - assert!(retrieved.data.is_none()); - assert_eq!(retrieved.commit_type, CommitType::EmptyAccount); - } - - #[test] - fn test_undelegate_flag() { - let (mut db, _file) = setup_test_db(); - let mut row = create_test_row(1, 0); - row.undelegate = true; - - db.insert_commit_status_rows(&[row.clone()]).unwrap(); - - let retrieved = db.get_commit_status(1, &row.pubkey).unwrap().unwrap(); - assert!(retrieved.undelegate); - } - - #[test] - fn test_flow_message_and_commit_id_roundtrip_boundaries() { - let (mut db, _file) = setup_test_db(); - - // Boundary IDs we care about for storage and retrieval. - // Skip u64::MAX due to the known overflow behavior in u64_into_i64. - let ids: [u64; 4] = - [0, i64::MAX as u64, (i64::MAX as u64) + 1, u64::MAX - 1]; - - // Prepare rows with message_id == commit_id == each boundary value. - let mut rows = Vec::new(); - for &id in &ids { - let mut row = create_test_row(id, id); - // give each row a distinct status/signatures pattern to make sure - // we don't accidentally mix them up in queries/updates - if id % 2 == 0 { - row.commit_status = CommitStatus::Pending; - } else { - row.commit_status = - CommitStatus::Succeeded(CommitStatusSignatures { - commit_stage_signature: Signature::new_unique(), - finalize_stage_signature: None, - }); - } - rows.push(row); - } - - // Insert all rows - db.insert_commit_status_rows(&rows).unwrap(); - - // 1) Retrieval by message_id should give back exactly the rows with that ID, - // and the internal IDs should be round-tripped correctly. - for row in &rows { - let fetched = db.get_commit_statuses_by_id(row.message_id).unwrap(); - // We inserted a single row for each (message_id, pubkey, commit_id) triple, - // so count should be 1 for this message_id. - assert_eq!( - fetched.len(), - 1, - "unexpected count for message_id={}", - row.message_id - ); - let got = &fetched[0]; - assert_eq!(got.message_id, row.message_id, "message_id mismatch"); - assert_eq!(got.commit_id, row.commit_id, "commit_id mismatch"); - assert_eq!(got.pubkey, row.pubkey, "pubkey mismatch"); - assert_eq!(got.commit_status, row.commit_status, "status mismatch"); - } - - // 2) Retrieval by (message_id, pubkey) via get_commit_status should match exactly. - for row in &rows { - let got = db - .get_commit_status(row.message_id, &row.pubkey) - .unwrap() - .unwrap(); - assert_eq!(got.message_id, row.message_id); - assert_eq!(got.commit_id, row.commit_id); - assert_eq!(got.pubkey, row.pubkey); - } - - // 3) Verify update by message_id preserves IDs and writes the new status. - // Use the smallest and largest IDs to stress the conversion. - let smallest_id = ids[0]; - let largest_id = ids[ids.len() - 1]; - - { - let target = - rows.iter().find(|r| r.message_id == smallest_id).unwrap(); - let new_status = CommitStatus::Failed; - db.update_status_by_message( - target.message_id, - &target.pubkey, - &new_status, - ) - .unwrap(); - - let got = db - .get_commit_status(target.message_id, &target.pubkey) - .unwrap() - .unwrap(); - assert_eq!(got.message_id, target.message_id); - assert_eq!(got.commit_id, target.commit_id); - assert_eq!(got.commit_status, new_status); - } - - // 4) Verify update by commit_id also works for large negative-mapped i64. - { - let target = - rows.iter().find(|r| r.commit_id == largest_id).unwrap(); - let new_status = CommitStatus::Succeeded(CommitStatusSignatures { - commit_stage_signature: Signature::new_unique(), - finalize_stage_signature: Some(Signature::new_unique()), - }); - db.update_status_by_commit( - target.commit_id, - &target.pubkey, - &new_status, - ) - .unwrap(); - - let got = db - .get_commit_status(target.message_id, &target.pubkey) - .unwrap() - .unwrap(); - assert_eq!(got.message_id, target.message_id); - assert_eq!(got.commit_id, target.commit_id); - assert_eq!(got.commit_status, new_status); - - // also verify get_signatures_by_commit returns the same signatures - let sigs = db - .get_signatures_by_commit(target.commit_id, &target.pubkey) - .unwrap() - .unwrap(); - if let CommitStatus::Succeeded(ss) = new_status { - assert_eq!( - sigs.commit_stage_signature, - ss.commit_stage_signature - ); - assert_eq!( - sigs.finalize_stage_signature, - ss.finalize_stage_signature - ); - } else { - panic!("unexpected status shape"); - } - } - - // 5) set_commit_id should update correctly across the boundary values - // (use XOR to create a different but valid u64). - for row in &rows { - let new_commit_id = row.commit_id ^ 0xDEAD_BEEF_DEAD_BEEF; - db.set_commit_id(row.message_id, &row.pubkey, new_commit_id) - .unwrap(); - let got = db - .get_commit_status(row.message_id, &row.pubkey) - .unwrap() - .unwrap(); - assert_eq!(got.message_id, row.message_id); - assert_eq!(got.commit_id, new_commit_id); - } - } -} diff --git a/magicblock-committor-service/src/persist/error.rs b/magicblock-committor-service/src/persist/error.rs deleted file mode 100644 index 49e0df299..000000000 --- a/magicblock-committor-service/src/persist/error.rs +++ /dev/null @@ -1,41 +0,0 @@ -use thiserror::Error; - -pub type CommitPersistResult = Result; - -#[derive(Error, Debug)] -pub enum CommitPersistError { - #[error("RusqliteError: '{0}' ({0:?})")] - RusqliteError(#[from] rusqlite::Error), - - #[error("ParsePubkeyError: '{0}' ({0:?})")] - ParsePubkeyError(#[from] solana_pubkey::ParsePubkeyError), - - #[error("ParseSignatureError: '{0}' ({0:?})")] - ParseSignatureError(#[from] solana_signature::ParseSignatureError), - - #[error("ParseHashError: '{0}' ({0:?})")] - ParseHashError(#[from] solana_hash::ParseHashError), - - #[error("Invalid Commit Type: '{0}' ({0:?})")] - InvalidCommitType(String), - - #[error("Invalid Commit Status: '{0}' ({0:?})")] - InvalidCommitStatus(String), - - #[error("Invalid Commit Strategy: '{0}' ({0:?})")] - InvalidCommitStrategy(String), - - #[error( - "Commit Status update requires status with bundle id: '{0}' ({0:?})" - )] - CommitStatusUpdateRequiresStatusWithBundleId(String), - - #[error("Commit Status needs bundle id: '{0}' ({0:?})")] - CommitStatusNeedsBundleId(String), - - #[error("Commit Status needs signatures: '{0}' ({0:?})")] - CommitStatusNeedsSignatures(String), - - #[error("Commit Status needs commit strategy: '{0}' ({0:?})")] - CommitStatusNeedsStrategy(String), -} diff --git a/magicblock-committor-service/src/persist/mod.rs b/magicblock-committor-service/src/persist/mod.rs deleted file mode 100644 index 6d7083055..000000000 --- a/magicblock-committor-service/src/persist/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -mod commit_persister; -mod db; -pub mod error; -mod types; -mod utils; - -pub use commit_persister::{IntentPersister, IntentPersisterImpl}; -pub use db::{CommitStatusRow, CommittsDb, MessageSignatures}; -pub use types::{ - CommitStatus, CommitStatusSignatures, CommitStrategy, CommitType, -}; diff --git a/magicblock-committor-service/src/persist/types/commit_status.rs b/magicblock-committor-service/src/persist/types/commit_status.rs deleted file mode 100644 index 9942dd296..000000000 --- a/magicblock-committor-service/src/persist/types/commit_status.rs +++ /dev/null @@ -1,161 +0,0 @@ -use std::fmt; - -use solana_signature::Signature; - -use crate::persist::error::CommitPersistError; - -/// The status of a committed account. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CommitStatus { - /// We sent the request to commit this account, but haven't received a result yet. - Pending, - /// No part of the commit pipeline succeeded. - /// The commit for this account needs to be restarted from scratch. - Failed, - /// The buffer and chunks account were initialized, but could either not - /// be retrieved or deserialized. It is recommended to fully re-initialize - /// them on retry. - BufferAndChunkPartiallyInitialized, - /// The buffer and chunks accounts were initialized and could be - /// deserialized, however we did not complete writing to them - /// We can reuse them on retry, but need to rewrite all chunks. - BufferAndChunkInitialized, - /// The buffer and chunks accounts were initialized and all data was - /// written to them (for data accounts). - /// This means on retry we can skip that step and just try to process - /// these buffers to complete the commit. - BufferAndChunkFullyInitialized, - /// The commit is part of a bundle that contains too many commits to be included - /// in a single transaction. Thus we cannot commit any of them. - PartOfTooLargeBundleToProcess, - /// The commit was properly initialized and added to a chunk of instructions to process - /// commits via a transaction. For large commits the buffer and chunk accounts were properly - /// prepared and haven't been closed. - FailedProcess(Option), - /// The commit was properly processed but the requested finalize transaction failed. - FailedFinalize(CommitStatusSignatures), - /// The commit was successfully processed and finalized. - Succeeded(CommitStatusSignatures), -} - -impl fmt::Display for CommitStatus { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - CommitStatus::Pending => write!(f, "Pending"), - CommitStatus::Failed => { - write!(f, "Failed") - } - CommitStatus::BufferAndChunkPartiallyInitialized => { - write!(f, "BufferAndChunkPartiallyInitialized") - } - CommitStatus::BufferAndChunkInitialized => { - write!(f, "BufferAndChunkInitialized") - } - CommitStatus::BufferAndChunkFullyInitialized => { - write!(f, "BufferAndChunkFullyInitialized") - } - CommitStatus::PartOfTooLargeBundleToProcess => { - write!(f, "PartOfTooLargeBundleToProcess") - } - CommitStatus::FailedProcess(sigs) => { - write!(f, "FailedProcess({:?})", sigs) - } - CommitStatus::FailedFinalize(sigs) => { - write!(f, "FailedFinalize({:?})", sigs) - } - CommitStatus::Succeeded(sigs) => { - write!(f, "Succeeded({:?})", sigs) - } - } - } -} - -impl TryFrom<(&str, Option)> for CommitStatus { - type Error = CommitPersistError; - - fn try_from( - (status, sigs): (&str, Option), - ) -> Result { - let get_sigs = || { - if let Some(sigs) = sigs.clone() { - Ok(sigs) - } else { - Err(CommitPersistError::CommitStatusNeedsSignatures( - status.to_string(), - )) - } - }; - - use CommitStatus::*; - match status { - "Pending" => Ok(Pending), - "Failed" => Ok(Failed), - "BufferAndChunkPartiallyInitialized" => { - Ok(BufferAndChunkPartiallyInitialized) - } - "BufferAndChunkInitialized" => Ok(BufferAndChunkInitialized), - "BufferAndChunkFullyInitialized" => { - Ok(BufferAndChunkFullyInitialized) - } - "PartOfTooLargeBundleToProcess" => { - Ok(PartOfTooLargeBundleToProcess) - } - "FailedProcess" => Ok(FailedProcess(sigs)), - "FailedFinalize" => Ok(FailedFinalize(get_sigs()?)), - "Succeeded" => Ok(Succeeded(get_sigs()?)), - _ => { - Err(CommitPersistError::InvalidCommitStatus(status.to_string())) - } - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommitStatusSignatures { - /// The signature of the transaction processing Commit stage - pub commit_stage_signature: Signature, - /// The signature of the transaction processing Finalize Stage. - /// If the account was not finalized or it failed then this is `None`. - /// If the finalize instruction was part of the Commit stage transaction then - /// this signature is the same as [Self::commit_stage_signature]. - pub finalize_stage_signature: Option, -} - -impl CommitStatus { - pub fn as_str(&self) -> &str { - use CommitStatus::*; - match self { - Pending => "Pending", - Failed => "Failed", - BufferAndChunkPartiallyInitialized => { - "BufferAndChunkPartiallyInitialized" - } - BufferAndChunkInitialized => "BufferAndChunkInitialized", - BufferAndChunkFullyInitialized => "BufferAndChunkFullyInitialized", - PartOfTooLargeBundleToProcess => "PartOfTooLargeBundleToProcess", - FailedProcess(_) => "FailedProcess", - FailedFinalize(_) => "FailedFinalize", - Succeeded(_) => "Succeeded", - } - } - - pub fn signatures(&self) -> Option { - use CommitStatus::*; - match self { - FailedProcess(sigs) => sigs.as_ref().cloned(), - FailedFinalize(sigs) => Some(sigs.clone()), - Succeeded(sigs) => Some(sigs.clone()), - _ => None, - } - } - - /// The commit fully succeeded and no retry is necessary. - pub fn is_complete(&self) -> bool { - use CommitStatus::*; - matches!(self, Succeeded(_)) - } - - pub fn all_completed(stages: &[Self]) -> bool { - stages.iter().all(Self::is_complete) - } -} diff --git a/magicblock-committor-service/src/persist/types/commit_strategy.rs b/magicblock-committor-service/src/persist/types/commit_strategy.rs deleted file mode 100644 index 3bf007333..000000000 --- a/magicblock-committor-service/src/persist/types/commit_strategy.rs +++ /dev/null @@ -1,72 +0,0 @@ -use crate::persist::error::CommitPersistError; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub enum CommitStrategy { - /// Args without the use of a lookup table - #[default] - StateArgs, - /// Args with the use of a lookup table - StateArgsWithLookupTable, - /// Buffer and chunks which has the most overhead - StateBuffer, - /// Buffer and chunks with the use of a lookup table - StateBufferWithLookupTable, - - /// Args without the use of a lookup table - DiffArgs, - /// Args with the use of a lookup table - DiffArgsWithLookupTable, - /// Buffer and chunks which has the most overhead - DiffBuffer, - /// Buffer and chunks with the use of a lookup table - DiffBufferWithLookupTable, -} - -impl CommitStrategy { - pub fn as_str(&self) -> &str { - use CommitStrategy::*; - match self { - StateArgs => "StateArgs", - StateArgsWithLookupTable => "StateArgsWithLookupTable", - StateBuffer => "StateBuffer", - StateBufferWithLookupTable => "StateBufferWithLookupTable", - DiffArgs => "DiffArgs", - DiffArgsWithLookupTable => "DiffArgsWithLookupTable", - DiffBuffer => "DiffBuffer", - DiffBufferWithLookupTable => "DiffBufferWithLookupTable", - } - } - - pub fn uses_lookup(&self) -> bool { - matches!( - self, - CommitStrategy::StateArgsWithLookupTable - | CommitStrategy::StateBufferWithLookupTable - | CommitStrategy::DiffArgsWithLookupTable - | CommitStrategy::DiffBufferWithLookupTable - ) - } -} - -impl TryFrom<&str> for CommitStrategy { - type Error = CommitPersistError; - fn try_from(value: &str) -> Result { - match value { - "Args" | "StateArgs" => Ok(Self::StateArgs), - "ArgsWithLookupTable" | "StateArgsWithLookupTable" => { - Ok(Self::StateArgsWithLookupTable) - } - "FromBuffer" | "StateBuffer" => Ok(Self::StateBuffer), - "FromBufferWithLookupTable" | "StateBufferWithLookupTable" => { - Ok(Self::StateBufferWithLookupTable) - } - "DiffArgs" => Ok(Self::DiffArgs), - "DiffArgsWithLookupTable" => Ok(Self::DiffArgsWithLookupTable), - "DiffBuffer" => Ok(Self::DiffBuffer), - "DiffBufferWithLookupTable" => Ok(Self::DiffBufferWithLookupTable), - _ => Err(CommitPersistError::InvalidCommitStrategy( - value.to_string(), - )), - } - } -} diff --git a/magicblock-committor-service/src/persist/types/commit_type.rs b/magicblock-committor-service/src/persist/types/commit_type.rs deleted file mode 100644 index 96324456b..000000000 --- a/magicblock-committor-service/src/persist/types/commit_type.rs +++ /dev/null @@ -1,28 +0,0 @@ -use crate::persist::error::CommitPersistError; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CommitType { - EmptyAccount, - DataAccount, -} - -impl TryFrom<&str> for CommitType { - type Error = CommitPersistError; - - fn try_from(value: &str) -> Result { - match value { - "EmptyAccount" => Ok(CommitType::EmptyAccount), - "DataAccount" => Ok(CommitType::DataAccount), - _ => Err(CommitPersistError::InvalidCommitType(value.to_string())), - } - } -} - -impl CommitType { - pub fn as_str(&self) -> &str { - match self { - CommitType::EmptyAccount => "EmptyAccount", - CommitType::DataAccount => "DataAccount", - } - } -} diff --git a/magicblock-committor-service/src/persist/types/mod.rs b/magicblock-committor-service/src/persist/types/mod.rs deleted file mode 100644 index b0c68fa59..000000000 --- a/magicblock-committor-service/src/persist/types/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod commit_status; -mod commit_strategy; -mod commit_type; - -pub use commit_status::*; -pub use commit_strategy::*; -pub use commit_type::*; diff --git a/magicblock-committor-service/src/persist/utils.rs b/magicblock-committor-service/src/persist/utils.rs deleted file mode 100644 index 3bdbda592..000000000 --- a/magicblock-committor-service/src/persist/utils.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -/// Fits a u64 into an i64, by mapping the range [0, i64::MAX] to itself, and -/// mapping the range [i64::MAX + 1, u64::MAX - 1] into the negative range of i64. -/// NOTE: this fails for u64::MAX -/// TODO(edwin): just store bit-copy in i64 -pub(crate) fn u64_into_i64(n: u64) -> i64 { - if n > i64::MAX as u64 { - -((n - i64::MAX as u64) as i64) - } else { - n as i64 - } -} - -/// Extracts a u64 that was fitted into an i64 by `u64_into_i64`. -pub(crate) fn i64_into_u64(n: i64) -> u64 { - if n < 0 { - n.unsigned_abs() + i64::MAX as u64 - } else { - n as u64 - } -} - -/// Gets the current timestamp in seconds since the Unix epoch -pub(crate) fn now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_utils; - - fn setup() { - test_utils::init_test_logger(); - } - - fn round_trip(u: u64) { - let i = u64_into_i64(u); - let u2 = i64_into_u64(i); - assert_eq!(u, u2); - } - - #[test] - fn test_u64_i64_conversion_via_round_trip() { - setup(); - round_trip(0); - round_trip(1); - round_trip(i64::MAX as u64); - round_trip(i64::MAX as u64 + 1); - - // NOTE: the below which points out that we cannot round trip u64::MAX, - assert_eq!(i64::MAX as u64 * 2 + 1, u64::MAX); - - // This is the largest we can roundtrip - round_trip(u64::MAX - 1); - round_trip(i64::MAX as u64 * 2); - - // This would fail: - // round_trip(u64::MAX); - } -} diff --git a/magicblock-committor-service/src/service.rs b/magicblock-committor-service/src/service.rs index ba7a0a419..8786a25d6 100644 --- a/magicblock-committor-service/src/service.rs +++ b/magicblock-committor-service/src/service.rs @@ -1,63 +1,65 @@ -pub mod intent_client; - use std::{ - collections::{HashMap, HashSet}, - future::Future, - mem, - sync::{Arc, Mutex}, + collections::HashSet, future::Future, mem, num::NonZeroUsize, sync::Arc, time::Duration, }; -use futures_util::future::join_all; -use intent_client::{ - ERIntentClient, InternalIntentClientError, ScheduledBaseIntentMeta, -}; use magicblock_account_cloner::ChainlinkCloner; use magicblock_chainlink::{ProdChainlink, ProdInnerChainlink}; -use magicblock_metrics::metrics::{self, AccountFetchOrigin}; -use magicblock_program::{ - magic_scheduled_base_intent::ScheduledIntentBundle, Pubkey, -}; +use magicblock_metrics::metrics::{self}; +use magicblock_program::{outbox_intent_bundles::OutboxIntentBundle, Pubkey}; +use solana_transaction::Transaction; use tokio::{ - sync::broadcast, task, task::{JoinError, JoinHandle}, }; use tokio_util::sync::CancellationToken; -use tracing::{error, info, instrument, warn}; +use tracing::error; use crate::{ - committor_processor::CommittorProcessor, error::CommittorServiceResult, - intent_execution_manager::BroadcastedIntentExecutionResult, + committor_processor::CommittorProcessor, + error::CommittorServiceResult, + intent_engine::db::BacklogDB, + intent_executor::error::IntentExecutorError, + outbox::{ + outbox_client::InternalOutboxClientError, + outbox_intent_bundles_reader::{ + OutboxIntentBundlesReader, OutboxIntentBundlesReaderError, + }, + OutboxClient, + }, }; -const POISONED_MUTEX_MSG: &str = "ServiceInner intents_meta_map mutex poisoned"; - pub type InnerChainlinkImpl = ProdInnerChainlink; pub type ChainlinkImpl = ProdChainlink; -pub enum IntentExecutionService { - Created(ServiceInner), +pub enum IntentExecutionService { + Created(ServiceInner), Started(JoinHandle<()>), Stopped, Error, } -impl IntentExecutionService +impl IntentExecutionService where - R: ERIntentClient, - R::Error: Into, + O: OutboxClient, + // OutboxClient errors should be convertible to Service errors + O::Error: Into, + // OutboxClient errors should be convertible to IntentExecutor error + O::Error: Into, + // OutboxReader errors should be convertible to Service errors + ::Error: + Into, { pub fn new( chainlink: Arc, - intent_rpc_client: R, - processor: Arc, + intent_client: Arc, + processor: Arc>, slot_interval: Duration, cancellation_token: CancellationToken, ) -> Self { Self::Created(ServiceInner::new( chainlink, - intent_rpc_client, + intent_client, processor, slot_interval, cancellation_token, @@ -93,59 +95,56 @@ where } } -pub struct ServiceInner { +pub struct ServiceInner { /// Chainlink for notifying of undelegations chainlink: Arc, /// ER client specific for Intent needs. Could be switched to RpcClient - intent_rpc_client: Arc, + outbox_client: Arc, /// Processor of accepted intents - processor: Arc, + processor: Arc>, /// Time interval to scrape MagicContext(ER slot interval) // TODO(edwin): can be removed if LatestBlocK moved into magicblock-core slot_interval: Duration, cancellation_token: CancellationToken, - /// Meta for ongoing executing intents - intents_meta_map: Arc>>, } -impl ServiceInner +impl ServiceInner where - R: ERIntentClient, - R::Error: Into, + O: OutboxClient, + // OutboxClient errors should be convertible to Service errors + O::Error: Into, + // OutboxClient errors should be convertible into IntentExecutor errors + O::Error: Into, + // OutboxReader errors should be convertible to Service errors + ::Error: + Into, { pub fn new( chainlink: Arc, - intent_rpc_client: R, - processor: Arc, + outbox_client: Arc, + processor: Arc>, slot_interval: Duration, cancellation_token: CancellationToken, ) -> Self { Self { chainlink, - intent_rpc_client: Arc::new(intent_rpc_client), + outbox_client, processor, slot_interval, cancellation_token, - intents_meta_map: Arc::new(Mutex::default()), } } - /// Starts 2 workers: one accepting intents, another awaiting and handling results fn start(self) -> JoinHandle<()> { - let result_subscriber = self.processor.subscribe_for_results(); - let cancellation_token = self.cancellation_token.clone(); - tokio::spawn(Self::result_processor( - result_subscriber, - cancellation_token, - self.intents_meta_map.clone(), - self.intent_rpc_client.clone(), - )); - tokio::task::spawn(self.accept_worker()) } async fn accept_worker(self) { - if let Err(err) = self.reschedule_pending_bundles().await { + // Reschedule existing outbox intents first + // We need to ensure that accounts in outbox a scheduled before + // we accept new incoming Intents + if let Err(err) = self.reschedule_intents().await { + // TODO(edwin): alerts error!(error = ?err, "Failed to reschedule pending bundles") } @@ -158,17 +157,15 @@ where } _ = interval.tick() => { let accept_result = self - .intent_rpc_client + .outbox_client .accept_scheduled_intents() .await; - let intent_bundles = match accept_result { - Ok(value) => value, - Err(err) => { - error!("Failed to accept intents: {}", err); - continue; - } - }; + let intent_bundles = accept_result.unwrap_or_else(|(accepted_intents, err)| { + error!("Failed to accept intents: {}", err); + accepted_intents + }); + let intent_bundles= intent_bundles.into_iter().map(OutboxIntentBundle::accepted).collect(); if let Err(err) = self.schedule_intent_execution(intent_bundles).await { error!("Failed to schedule intent execution: {}", err); } @@ -177,29 +174,50 @@ where } } - async fn reschedule_pending_bundles(&self) -> CommittorServiceResult<()> { - // Fetch pending bundles from DB - let mut bundles = - self.processor.pending_intent_bundles().await.inspect_err(|err| { - error!(error = ?err, "Failed to load pending intent bundles for recovery"); - })?; - if bundles.is_empty() { - return Ok(()); - } + async fn reschedule_intents( + &self, + ) -> Result<(), IntentExecutionServiceError> { + /// Number of intents rescheduled at once + const RESCHEDULE_CHUNK_SIZE: NonZeroUsize = + NonZeroUsize::new(1000).unwrap(); - // Retain only recoverable bundles - self.retain_recoverable_intent_bundles(&mut bundles).await; + let mut outbox_bundles_reader = self.outbox_client.outbox_reader(); + loop { + // Read by chunks in order not to overload `IntentExecutionEngine` + let mut intent_bundles_chunk = outbox_bundles_reader + .read(RESCHEDULE_CHUNK_SIZE.get()) + .await + .map_err(Into::into)?; + // Original blockhash is stale after restart; signal recovery so + // notify_commit_sent rebuilds the tx with a fresh ER blockhash. + intent_bundles_chunk.iter_mut().for_each(|b| { + b.inner.sent_transaction = Transaction::default() + }); + if intent_bundles_chunk.is_empty() { + return Ok(()); + } - // Schedule without initial persisitance as bundle already exists in db - self.process_intent_bundles(bundles, |bundles| { - self.processor.schedule_recovered_intent_bundles(bundles) - }) - .await + let read_len = intent_bundles_chunk.len(); + // Schedule without initial persistence as bundle already exists in db + let result = self + .process_intent_bundles(intent_bundles_chunk, |bundles| { + self.processor.schedule_intent_bundles(bundles) + }) + .await; + if let Err(err) = result { + error!(error = ?err, "Failed to reschedule pending bundles") + } + + // Check if we've rescheduled intents from Outbox + if read_len != RESCHEDULE_CHUNK_SIZE.get() { + return Ok(()); + } + } } async fn schedule_intent_execution( &self, - intent_bundles: Vec, + intent_bundles: Vec, ) -> CommittorServiceResult<()> { if intent_bundles.is_empty() { return Ok(()); @@ -215,48 +233,32 @@ where async fn process_intent_bundles( &self, - intent_bundles: Vec, + intent_bundles: Vec, schedule: F, ) -> CommittorServiceResult<()> where - F: FnOnce(Vec) -> Fut, + F: FnOnce(Vec) -> Fut, Fut: Future>, { if intent_bundles.is_empty() { return Ok(()); } - // Add metas for intent we schedule - let intent_ids: Vec; let pubkeys_being_undelegated = { - let mut intent_metas = - self.intents_meta_map.lock().expect(POISONED_MUTEX_MSG); let mut pubkeys_being_undelegated = HashSet::::new(); intent_bundles.iter().for_each(|intent| { - intent_metas - .insert(intent.id, ScheduledBaseIntentMeta::new(intent)); if let Some(undelegate) = intent.get_undelegate_intent_pubkeys() { pubkeys_being_undelegated.extend(undelegate); } }); - intent_ids = intent_bundles.iter().map(|b| b.id).collect(); pubkeys_being_undelegated.into_iter().collect::>() }; self.process_undelegation_requests(pubkeys_being_undelegated) .await; - let result = schedule(intent_bundles).await; - // If scheduling failed remove from map - if result.is_err() { - let mut intent_metas = - self.intents_meta_map.lock().expect(POISONED_MUTEX_MSG); - intent_ids.iter().for_each(|id| { - intent_metas.remove(id); - }); - } - result + schedule(intent_bundles).await } async fn process_undelegation_requests(&self, pubkeys: Vec) { @@ -293,127 +295,6 @@ where ); } } - - #[instrument(skip( - result_subscription, - cancellation_token, - intents_meta_map, - intent_client - ))] - async fn result_processor( - mut result_subscription: broadcast::Receiver< - BroadcastedIntentExecutionResult, - >, - cancellation_token: CancellationToken, - intents_meta_map: Arc>>, - intent_client: Arc, - ) { - loop { - let execution_result = tokio::select! { - biased; - _ = cancellation_token.cancelled() => { - info!("Shutting down"); - return; - } - execution_result = result_subscription.recv() => { - match execution_result { - Ok(result) => result, - Err(broadcast::error::RecvError::Closed) => { - info!("Intent execution service shut down"); - break; - } - Err(broadcast::error::RecvError::Lagged(skipped)) => { - // SAFETY: This shouldn't happen as our tx execution is faster than Intent execution on Base layer - // If this ever happens it requires investigation - error!(skipped_count = skipped, "Lagging behind intent execution"); - continue; - } - } - } - }; - - if let Err(err) = ServiceInner::::process_execution_result( - &intent_client, - execution_result, - &intents_meta_map, - ) - .await - { - error!(error = ?err, "Failed process intent execution results"); - } - } - } - - async fn process_execution_result( - intent_client: &Arc, - execution_result: BroadcastedIntentExecutionResult, - intents_meta_map: &Arc>>, - ) -> Result<(), R::Error> { - let intent_id = execution_result.id; - let Some(intent_meta) = intents_meta_map - .lock() - .expect(POISONED_MUTEX_MSG) - .remove(&intent_id) - else { - // Possible if we have duplicate Intents - // First one will remove id from map and second could fail. - // This should not happen and needs investigation! - error!(intent_id, "Failed to find intent metadata"); - return Ok(()); - }; - - intent_client - .notify_commit_sent(intent_meta, execution_result) - .await?; - - Ok(()) - } - - /// Retains bundles whose accounts are still delegated - async fn retain_recoverable_intent_bundles( - &self, - bundles: &mut Vec, - ) { - let results = join_all( - bundles.iter().map(|b| b.get_all_committed_pubkeys()).map( - |pubkeys| async move { - self.chainlink - .accounts_delegated_on_base_and_er( - &pubkeys, - AccountFetchOrigin::GetAccount, - ) - .await - }, - ), - ) - .await; - - let mut results_iter = results.into_iter(); - bundles.retain(|bundle| { - let Some(result) = results_iter.next() else { - error!("Results and bundles must have equal length"); - return false; - }; - match result { - Ok(delegated) if delegated.iter().all(|d| *d) => true, - Ok(_) => { - warn!( - intent_id = bundle.id, - "Skipping recovered commit intent because not all accounts are delegated on base and ER" - ); - false - } - Err(err) => { - error!( - intent_id = bundle.id, - error = ?err, - "Failed to verify recovered commit intent accounts" - ); - false - } - } - }); - } } #[derive(thiserror::Error, Debug)] @@ -423,5 +304,9 @@ pub enum IntentExecutionServiceError { #[error("JoinError: {0}")] JoinError(#[from] JoinError), #[error("IntentRpcClientError: {0}")] - IntentRpcClientError(#[from] InternalIntentClientError), + IntentRpcClientError(#[from] InternalOutboxClientError), + #[error("OutboxReaderError")] + OutboxReaderError(#[from] OutboxIntentBundlesReaderError), + #[error("IntentExecutorError: {0}")] + IntentExecutorError(#[from] Box), } diff --git a/magicblock-committor-service/src/service/intent_client.rs b/magicblock-committor-service/src/service/intent_client.rs deleted file mode 100644 index b0fb9544d..000000000 --- a/magicblock-committor-service/src/service/intent_client.rs +++ /dev/null @@ -1,258 +0,0 @@ -use std::{mem, sync::Arc}; - -use async_trait::async_trait; -use magicblock_accounts_db::{traits::AccountsBank, AccountsDb}; -use magicblock_core::{ - link::transactions::{with_encoded, TransactionSchedulerHandle}, - traits::LatestBlockProvider, -}; -use magicblock_program::{ - instruction_utils::InstructionUtils, - magic_scheduled_base_intent::ScheduledIntentBundle, - register_scheduled_commit_sent, MagicContext, SentCommit, - TransactionScheduler, MAGIC_CONTEXT_PUBKEY, -}; -use solana_account::ReadableAccount; -use solana_hash::Hash; -use solana_pubkey::Pubkey; -use solana_transaction::Transaction; -use solana_transaction_error::TransactionError; -use tracing::{debug, error, info}; - -use crate::{ - intent_execution_manager::BroadcastedIntentExecutionResult, - intent_executor::ExecutionOutput, -}; - -/// Tracks the `ScheduledCommitSent` transaction for an in-flight intent. -/// -/// Bundles recovered from persistence have no pre-built transaction: the -/// original blockhash is likely expired by restart time. `Recovered` signals -/// that the transaction must be constructed at notification time using a -/// fresh ER blockhash. -#[derive(Default)] -pub enum IntentSentTransaction { - Known(Transaction), - #[default] - Recovered, -} - -pub struct ScheduledBaseIntentMeta { - slot: u64, - blockhash: Hash, - payer: Pubkey, - included_pubkeys: Vec, - pub(crate) intent_sent_transaction: IntentSentTransaction, - requested_undelegation: bool, -} - -impl ScheduledBaseIntentMeta { - pub fn new(intent: &ScheduledIntentBundle) -> Self { - Self { - slot: intent.slot, - blockhash: intent.blockhash, - payer: intent.payer, - included_pubkeys: intent.get_all_committed_pubkeys(), - intent_sent_transaction: if intent - .sent_transaction - .signatures - .is_empty() - { - IntentSentTransaction::Recovered - } else { - IntentSentTransaction::Known(intent.sent_transaction.clone()) - }, - requested_undelegation: intent.has_undelegate_intent(), - } - } -} - -#[async_trait] -pub trait ERIntentClient: Send + Sync + 'static { - type Error: std::error::Error + Send; - - /// Executes `Accept` tx and returns accepted intents - async fn accept_scheduled_intents( - &self, - ) -> Result, Self::Error>; - - /// Processes intent results, submitting them on chain(ER) - async fn notify_commit_sent( - &self, - meta: ScheduledBaseIntentMeta, - result: BroadcastedIntentExecutionResult, - ) -> Result<(), Self::Error>; - - // TODO(edwin): probably more proper place to load pending intent - // CommittorProcessor::pending_intent_bundles could be moved here in the future -} - -pub struct InternalIntentRpcClient { - /// Provides access to MagicContext - accounts_db: Arc, - /// Internal endpoint for scheduling ER TXs - transaction_scheduler: TransactionSchedulerHandle, - /// Provides access to ER latest block for TX creation - latest_block_provider: L, -} - -impl InternalIntentRpcClient { - pub fn new( - accounts_db: Arc, - transaction_scheduler: TransactionSchedulerHandle, - latest_block_provider: L, - ) -> Self { - Self { - accounts_db, - transaction_scheduler, - latest_block_provider, - } - } - - /// Sends transaction to move the scheduled commits from the `MagicContext` - /// to the global ScheduledCommit store - async fn send_accept_tx(&self) -> Result<(), InternalIntentClientError> { - let tx = InstructionUtils::accept_scheduled_commits( - self.latest_block_provider.blockhash(), - ); - let encoded_tx = with_encoded(tx).inspect_err(|err| { - error!(error = ?err, "Failed to bincode intent transaction"); - })?; - self.transaction_scheduler - .execute(encoded_tx) - .await - .inspect_err(|err| { - error!(error = ?err, "Failed to accept scheduled commits"); - })?; - - Ok(()) - } -} - -#[async_trait] -impl ERIntentClient for InternalIntentRpcClient { - type Error = InternalIntentClientError; - - async fn accept_scheduled_intents( - &self, - ) -> Result, Self::Error> { - // If accounts were scheduled to be committed, we accept them here - // and processs the commits - let magic_context_acc = - self.accounts_db.get_account(&MAGIC_CONTEXT_PUBKEY).expect( - "Validator found to be running without MagicContext account!", - ); - if !MagicContext::has_scheduled_commits(magic_context_acc.data()) { - return Ok(vec![]); - } - self.send_accept_tx().await?; - - // Return intents from global store - Ok(TransactionScheduler::default().take_scheduled_intent_bundles()) - } - - async fn notify_commit_sent( - &self, - mut meta: ScheduledBaseIntentMeta, - result: BroadcastedIntentExecutionResult, - ) -> Result<(), Self::Error> { - let intent_id = result.id; - let tx = match mem::take(&mut meta.intent_sent_transaction) { - IntentSentTransaction::Known(tx) => tx, - IntentSentTransaction::Recovered => { - let blockhash = self.latest_block_provider.blockhash(); - InstructionUtils::scheduled_commit_sent(intent_id, blockhash) - } - }; - let sent_commit = build_sent_commit(meta, &result); - register_scheduled_commit_sent(sent_commit); - let txn = with_encoded(tx).inspect_err(|err| { - // Unreachable case, all intent transactions are smaller than 64KB by construction - error!(error = ?err, "Failed to bincode intent transaction"); - })?; - self.transaction_scheduler - .execute(txn) - .await - .inspect(|_| debug!("Sent commit signaled")) - .inspect_err( - |err| error!(error = ?err, "Failed to signal sent commit"), - )?; - - Ok(()) - } -} - -fn build_sent_commit( - meta: ScheduledBaseIntentMeta, - result: &BroadcastedIntentExecutionResult, -) -> SentCommit { - let intent_id = result.id; - let error_message = result.as_ref().err().map(|err| format!("{:?}", err)); - let chain_signatures = match &result.inner { - Ok(value) => match value { - ExecutionOutput::SingleStage(signature) => vec![*signature], - ExecutionOutput::TwoStage { - commit_signature, - finalize_signature, - } => vec![*commit_signature, *finalize_signature], - }, - Err(err) => { - error!( - "Failed to commit intent: {}, slot: {}, blockhash: {}. {:?}", - intent_id, meta.slot, meta.blockhash, err - ); - err.signatures() - .map(|(commit, finalize)| { - finalize - .map(|finalize| vec![commit, finalize]) - .unwrap_or(vec![commit]) - }) - .unwrap_or_default() - } - }; - let patched_errors = result - .patched_errors - .iter() - .map(|err| { - info!("Patched intent: {}. error was: {}", intent_id, err); - err.to_string() - }) - .collect(); - - let callbacks_report = result - .callbacks_report - .iter() - .map(|r| match r { - Ok(sig) => { - format!("OK: {sig}") - } - Err(err) => { - error!( - "Callback failed to schedule: {}. error: {}", - intent_id, err - ); - format!("ERR: {err}") - } - }) - .collect(); - - SentCommit { - message_id: intent_id, - slot: meta.slot, - blockhash: meta.blockhash, - payer: meta.payer, - chain_signatures, - included_pubkeys: meta.included_pubkeys, - excluded_pubkeys: vec![], - requested_undelegation: meta.requested_undelegation, - error_message, - patched_errors, - callbacks_scheduling_results: callbacks_report, - } -} - -#[derive(thiserror::Error, Debug)] -pub enum InternalIntentClientError { - #[error("TransactionError: {0}")] - TransactionError(#[from] TransactionError), -} diff --git a/magicblock-committor-service/src/tasks/commit_finalize_task.rs b/magicblock-committor-service/src/tasks/commit_finalize_task.rs index 6a174f582..0f8f98999 100644 --- a/magicblock-committor-service/src/tasks/commit_finalize_task.rs +++ b/magicblock-committor-service/src/tasks/commit_finalize_task.rs @@ -11,11 +11,11 @@ use solana_instruction::Instruction; use solana_pubkey::Pubkey; use crate::{ - consts::MAX_WRITE_CHUNK_SIZE, tasks::{ commit_task::{CommitBufferStage, CommitDelivery}, BaseTask, BaseTaskImpl, PreparationTask, }, + utils::MAX_WRITE_CHUNK_SIZE, }; /// A task that commits a delegated account's state to the base layer and finalizes it in the same diff --git a/magicblock-committor-service/src/tasks/commit_task.rs b/magicblock-committor-service/src/tasks/commit_task.rs index 4d4ee77d1..3ff694d34 100644 --- a/magicblock-committor-service/src/tasks/commit_task.rs +++ b/magicblock-committor-service/src/tasks/commit_task.rs @@ -10,8 +10,8 @@ use solana_instruction::Instruction; use solana_pubkey::Pubkey; use crate::{ - consts::MAX_WRITE_CHUNK_SIZE, tasks::{BaseTask, BaseTaskImpl, CleanupTask, PreparationTask}, + utils::MAX_WRITE_CHUNK_SIZE, }; /// Lifecycle stage of a buffer used for commit delivery. diff --git a/magicblock-committor-service/src/tasks/mod.rs b/magicblock-committor-service/src/tasks/mod.rs index 7640ff74e..46e1bf83d 100644 --- a/magicblock-committor-service/src/tasks/mod.rs +++ b/magicblock-committor-service/src/tasks/mod.rs @@ -19,6 +19,7 @@ use solana_pubkey::Pubkey; pub mod commit_finalize_task; pub mod commit_task; pub mod task_builder; +pub mod task_info_fetcher; pub mod task_strategist; pub mod utils; @@ -724,9 +725,7 @@ fn test_close_buffer_limit() { use crate::{ test_utils, - transactions::{ - serialized_transaction_size, MAX_TRANSACTION_WIRE_SIZE, - }, + utils::{serialized_transaction_size, MAX_TRANSACTION_WIRE_SIZE}, }; test_utils::init_test_logger(); diff --git a/magicblock-committor-service/src/tasks/task_builder.rs b/magicblock-committor-service/src/tasks/task_builder.rs index 515e925f5..adf81fc13 100644 --- a/magicblock-committor-service/src/tasks/task_builder.rs +++ b/magicblock-committor-service/src/tasks/task_builder.rs @@ -12,25 +12,21 @@ use solana_pubkey::Pubkey; use solana_signature::Signature; use tracing::error; -use crate::{ - intent_executor::task_info_fetcher::{ +use crate::tasks::{ + commit_task::{CommitDelivery, CommitTask}, + task_info_fetcher::{ TaskInfoFetcher, TaskInfoFetcherError, TaskInfoFetcherResult, }, - persist::IntentPersister, - tasks::{ - commit_task::{CommitDelivery, CommitTask}, - BaseActionTask, BaseActionTaskV1, BaseActionTaskV2, BaseTaskImpl, - CommitFinalizeTask, FinalizeTask, UndelegateTask, - }, + BaseActionTask, BaseActionTaskV1, BaseActionTaskV2, BaseTaskImpl, + CommitFinalizeTask, FinalizeTask, UndelegateTask, }; #[async_trait] pub trait TasksBuilder { // Creates tasks for commit stage - async fn commit_tasks( + async fn commit_tasks( commit_id_fetcher: &Arc, base_intent: &ScheduledIntentBundle, - persister: &Option

, ) -> TaskBuilderResult>; // Create tasks for finalize stage @@ -137,10 +133,9 @@ impl TaskBuilderImpl { .await } - async fn fetch_commit_stage_info( + async fn fetch_commit_stage_info( intent_bundle: &ScheduledIntentBundle, task_info_fetcher: &Arc, - persister: &Option

, ) -> TaskBuilderResult { // Fetch necessary data for BaseTasks creation let all_committed_accounts = intent_bundle.get_all_committed_accounts(); @@ -169,15 +164,6 @@ impl TaskBuilderImpl { Default::default() }); - // Persist commit ids for commitees - commit_nonces - .iter() - .for_each(|(pubkey, commit_id) | { - if let Err(err) = persister.set_commit_id(intent_bundle.id, pubkey, *commit_id) { - error!(intent_id = intent_bundle.id, pubkey = %pubkey, error = ?err, "Failed to persist commit id"); - } - }); - Ok(CommitStageTaskInfo { commit_nonces, base_accounts, @@ -215,10 +201,9 @@ impl TaskBuilderImpl { #[async_trait] impl TasksBuilder for TaskBuilderImpl { /// Returns [`BaseTaskImpl`]s for Commit stage - async fn commit_tasks( + async fn commit_tasks( task_info_fetcher: &Arc, intent_bundle: &ScheduledIntentBundle, - persister: &Option

, ) -> TaskBuilderResult> { let mut tasks = Vec::new(); // Add standalone actions first @@ -230,12 +215,8 @@ impl TasksBuilder for TaskBuilderImpl { let CommitStageTaskInfo { mut commit_nonces, mut base_accounts, - } = Self::fetch_commit_stage_info( - intent_bundle, - task_info_fetcher, - persister, - ) - .await?; + } = Self::fetch_commit_stage_info(intent_bundle, task_info_fetcher) + .await?; // Create tasks per intent type if let Some(ref value) = intent_bundle.intent_bundle.commit { diff --git a/magicblock-committor-service/src/intent_executor/task_info_fetcher.rs b/magicblock-committor-service/src/tasks/task_info_fetcher.rs similarity index 100% rename from magicblock-committor-service/src/intent_executor/task_info_fetcher.rs rename to magicblock-committor-service/src/tasks/task_info_fetcher.rs diff --git a/magicblock-committor-service/src/tasks/task_strategist.rs b/magicblock-committor-service/src/tasks/task_strategist.rs index b55cbd7dc..ea7e9813f 100644 --- a/magicblock-committor-service/src/tasks/task_strategist.rs +++ b/magicblock-committor-service/src/tasks/task_strategist.rs @@ -4,18 +4,14 @@ use magicblock_core::intent::BaseActionCallback; use solana_keypair::Keypair; use solana_pubkey::Pubkey; use solana_signer::{Signer, SignerError}; -use tracing::error; use crate::{ - persist::{CommitStrategy, IntentPersister}, - tasks::{ - commit_task::CommitDelivery, utils::TransactionUtils, BaseActionTask, - BaseTask, BaseTaskImpl, - }, - transactions::{serialized_transaction_size, MAX_TRANSACTION_WIRE_SIZE}, + tasks::{utils::TransactionUtils, BaseActionTask, BaseTask, BaseTaskImpl}, + utils::{serialized_transaction_size, MAX_TRANSACTION_WIRE_SIZE}, }; -#[derive(Default)] +#[derive(Default, Clone)] +#[cfg_attr(feature = "dev-context-only-utils", derive(Debug))] pub struct TransactionStrategy { pub optimized_tasks: Vec, pub lookup_tables_keys: Vec, @@ -98,24 +94,35 @@ impl TransactionStrategy { pub enum StrategyExecutionMode { SingleStage(TransactionStrategy), - TwoStage { - commit_stage: TransactionStrategy, - finalize_stage: TransactionStrategy, - }, + TwoStage(TwoStageExecutionMode), } impl StrategyExecutionMode { pub fn uses_alts(&self) -> bool { match self { Self::SingleStage(value) => value.uses_alts(), - Self::TwoStage { - commit_stage, - finalize_stage, - } => commit_stage.uses_alts() || finalize_stage.uses_alts(), + Self::TwoStage(value) => value.uses_alts(), } } } +pub struct TwoStageExecutionMode { + pub commit_stage: TransactionStrategy, + pub finalize_stage: TransactionStrategy, +} + +impl TwoStageExecutionMode { + pub fn uses_alts(&self) -> bool { + self.commit_stage.uses_alts() || self.finalize_stage.uses_alts() + } +} + +impl From for StrategyExecutionMode { + fn from(value: TwoStageExecutionMode) -> Self { + Self::TwoStage(value) + } +} + /// Takes [`BaseTask`]s and chooses the best way to fit them in TX /// It may change Task execution strategy so all task would fit in tx pub struct TaskStrategist; @@ -123,11 +130,10 @@ impl TaskStrategist { /// Builds execution strategy from [`BaseTask`]s /// 1. Optimizes tasks to fit in TX /// 2. Chooses the fastest execution mode for Tasks - pub fn build_execution_strategy( + pub fn build_execution_strategy( commit_tasks: Vec, finalize_tasks: Vec, authority: &Pubkey, - persister: &Option

, ) -> TaskStrategistResult { const MAX_UNITED_TASKS_LEN: usize = 22; @@ -142,33 +148,31 @@ impl TaskStrategist { commit_tasks, finalize_tasks, authority, - persister, - ); + ) + .map(Into::into); } // Clone tasks since strategies applied to united case maybe suboptimal for regular one // Unite tasks to attempt running as single tx let single_stage_tasks = [commit_tasks.clone(), finalize_tasks.clone()].concat(); - let single_stage_strategy = match TaskStrategist::build_strategy( - single_stage_tasks, - authority, - persister, - ) { - Ok(strategy) => StrategyExecutionMode::SingleStage(strategy), - Err(TaskStrategistError::FailedToFitError) => { - // If Tasks can't fit in SingleStage - use TwpStage execution - return Self::build_two_stage( - commit_tasks, - finalize_tasks, - authority, - persister, - ); - } - Err(TaskStrategistError::SignerError(err)) => { - return Err(err.into()) - } - }; + let single_stage_strategy = + match TaskStrategist::build_strategy(single_stage_tasks, authority) + { + Ok(strategy) => StrategyExecutionMode::SingleStage(strategy), + Err(TaskStrategistError::FailedToFitError) => { + // If Tasks can't fit in SingleStage - use TwpStage execution + return Self::build_two_stage( + commit_tasks, + finalize_tasks, + authority, + ) + .map(Into::into); + } + Err(TaskStrategistError::SignerError(err)) => { + return Err(err.into()) + } + }; // If ALTs aren't used then we sure this will be optimal - return if !single_stage_strategy.uses_alts() { @@ -178,37 +182,29 @@ impl TaskStrategist { // As ALTs take a very long time to activate // it is actually faster to execute in TwoStage mode // unless TwoStage also uses ALTs - let two_stage = Self::build_two_stage( - commit_tasks, - finalize_tasks, - authority, - persister, - )?; + let two_stage = + Self::build_two_stage(commit_tasks, finalize_tasks, authority)?; if two_stage.uses_alts() { Ok(single_stage_strategy) } else { - Ok(two_stage) + Ok(two_stage.into()) } } - fn build_two_stage( + pub fn build_two_stage( commit_tasks: Vec, finalize_tasks: Vec, authority: &Pubkey, - persister: &Option

, - ) -> TaskStrategistResult { + ) -> TaskStrategistResult { // Build strategy for Commit stage let commit_strategy = - TaskStrategist::build_strategy(commit_tasks, authority, persister)?; + TaskStrategist::build_strategy(commit_tasks, authority)?; // Build strategy for Finalize stage - let finalize_strategy = TaskStrategist::build_strategy( - finalize_tasks, - authority, - persister, - )?; + let finalize_strategy = + TaskStrategist::build_strategy(finalize_tasks, authority)?; - Ok(StrategyExecutionMode::TwoStage { + Ok(TwoStageExecutionMode { commit_stage: commit_strategy, finalize_stage: finalize_strategy, }) @@ -216,20 +212,15 @@ impl TaskStrategist { /// Returns [`TransactionStrategy`] for tasks /// Returns Error if all optimizations weren't enough - pub fn build_strategy( + pub fn build_strategy( mut tasks: Vec, validator: &Pubkey, - persistor: &Option

, ) -> TaskStrategistResult { // Attempt optimizing tasks themselves(using buffers) if Self::try_optimize_tx_size_if_needed(&mut tasks)? <= MAX_TRANSACTION_WIRE_SIZE { // Persist tasks strategy - if let Some(persistor) = persistor { - Self::persist_tasks_strategy(persistor, &tasks, false); - } - Ok(TransactionStrategy { optimized_tasks: tasks, lookup_tables_keys: vec![], @@ -239,11 +230,6 @@ impl TaskStrategist { // In case task optimization didn't work // attempt using lookup tables for all keys involved in tasks else if Self::attempt_lookup_tables(&tasks) { - // Persist tasks strategy - if let Some(persistor) = persistor { - Self::persist_tasks_strategy(persistor, &tasks, true); - } - // Get lookup table keys let lookup_tables_keys = Self::collect_lookup_table_keys(validator, &tasks); @@ -315,77 +301,6 @@ impl TaskStrategist { ) } - fn persist_tasks_strategy( - persistor: &P, - tasks: &[BaseTaskImpl], - uses_lookup_tables: bool, - ) { - let commit_strategy_from_delivery = - |delivery: &CommitDelivery| match delivery { - CommitDelivery::StateInArgs => { - if uses_lookup_tables { - CommitStrategy::StateArgsWithLookupTable - } else { - CommitStrategy::StateArgs - } - } - CommitDelivery::DiffInArgs { .. } => { - if uses_lookup_tables { - CommitStrategy::DiffArgsWithLookupTable - } else { - CommitStrategy::DiffArgs - } - } - CommitDelivery::StateInBuffer { .. } => { - if uses_lookup_tables { - CommitStrategy::StateBufferWithLookupTable - } else { - CommitStrategy::StateBuffer - } - } - CommitDelivery::DiffInBuffer { .. } => { - if uses_lookup_tables { - CommitStrategy::DiffBufferWithLookupTable - } else { - CommitStrategy::DiffBuffer - } - } - }; - - for task in tasks { - let (commit_id, pubkey, commit_strategy) = match task { - BaseTaskImpl::Commit(commit_task) => ( - commit_task.commit_id, - commit_task.committed_account.pubkey, - commit_strategy_from_delivery( - &commit_task.delivery_details, - ), - ), - BaseTaskImpl::CommitFinalize(commit_finalize_task) => ( - commit_finalize_task.commit_id, - commit_finalize_task.committed_account.pubkey, - commit_strategy_from_delivery( - &commit_finalize_task.delivery, - ), - ), - _ => continue, - }; - if let Err(err) = persistor.set_commit_strategy( - commit_id, - &pubkey, - commit_strategy, - ) { - error!( - commit_id = %commit_id, - pubkey = %pubkey, - strategy = commit_strategy.as_str(), - error = ?err, - "Failed to persist commit strategy" - ); - } - } - } - /// Optimizes tasks so as to bring the transaction size within the limit [`MAX_TRANSACTION_WIRE_SIZE`] /// Returns Ok(size of tx after optimizations) else Err(SignerError). /// Note that the returned size, though possibly optimized one, may still not be under @@ -481,16 +396,13 @@ mod tests { use super::*; use crate::{ - intent_execution_manager::intent_scheduler::create_test_intent, - intent_executor::task_info_fetcher::{ - TaskInfoFetcher, TaskInfoFetcherResult, - }, - persist::IntentPersisterImpl, + intent_engine::intent_scheduler::create_test_intent, tasks::{ commit_task::CommitTask, task_builder::{ TaskBuilderImpl, TasksBuilder, COMMIT_STATE_SIZE_THRESHOLD, }, + task_info_fetcher::{TaskInfoFetcher, TaskInfoFetcherResult}, BaseActionTask, BaseActionTaskV1, FinalizeTask, TaskStrategy, UndelegateTask, }, @@ -617,12 +529,8 @@ mod tests { let task = create_test_commit_task(1, 100, 0); let tasks = vec![task.into()]; - let strategy = TaskStrategist::build_strategy( - tasks, - &validator, - &None::, - ) - .expect("Should build strategy"); + let strategy = TaskStrategist::build_strategy(tasks, &validator) + .expect("Should build strategy"); assert_eq!(strategy.optimized_tasks.len(), 1); assert!(strategy.lookup_tables_keys.is_empty()); @@ -635,12 +543,8 @@ mod tests { let task = create_test_commit_task(1, 1000, 0); // Large task let tasks = vec![task.into()]; - let strategy = TaskStrategist::build_strategy( - tasks, - &validator, - &None::, - ) - .expect("Should build strategy with buffer optimization"); + let strategy = TaskStrategist::build_strategy(tasks, &validator) + .expect("Should build strategy with buffer optimization"); assert_eq!(strategy.optimized_tasks.len(), 1); assert!(matches!( @@ -656,12 +560,8 @@ mod tests { let task = create_test_commit_task(1, 66_000, 0); // Large task let tasks = vec![task.into()]; - let strategy = TaskStrategist::build_strategy( - tasks, - &validator, - &None::, - ) - .expect("Should build strategy with buffer optimization"); + let strategy = TaskStrategist::build_strategy(tasks, &validator) + .expect("Should build strategy with buffer optimization"); assert_eq!(strategy.optimized_tasks.len(), 1); assert!(matches!( @@ -678,12 +578,8 @@ mod tests { create_test_commit_task(1, 66_000, COMMIT_STATE_SIZE_THRESHOLD); // large account but small diff let tasks = vec![task.into()]; - let strategy = TaskStrategist::build_strategy( - tasks, - &validator, - &None::, - ) - .expect("Should build strategy with buffer optimization"); + let strategy = TaskStrategist::build_strategy(tasks, &validator) + .expect("Should build strategy with buffer optimization"); assert_eq!(strategy.optimized_tasks.len(), 1); assert_eq!(strategy.optimized_tasks[0].strategy(), TaskStrategy::Args); @@ -698,12 +594,8 @@ mod tests { create_test_commit_task(1, 66_000, COMMIT_STATE_SIZE_THRESHOLD + 1); // large account but small diff let tasks = vec![task.into()]; - let strategy = TaskStrategist::build_strategy( - tasks, - &validator, - &None::, - ) - .expect("Should build strategy with buffer optimization"); + let strategy = TaskStrategist::build_strategy(tasks, &validator) + .expect("Should build strategy with buffer optimization"); assert_eq!(strategy.optimized_tasks.len(), 1); assert_eq!(strategy.optimized_tasks[0].strategy(), TaskStrategy::Args); @@ -717,12 +609,8 @@ mod tests { create_test_commit_task(1, 66_000, COMMIT_STATE_SIZE_THRESHOLD * 4); // large account but small diff let tasks = vec![task.into()]; - let strategy = TaskStrategist::build_strategy( - tasks, - &validator, - &None::, - ) - .expect("Should build strategy with buffer optimization"); + let strategy = TaskStrategist::build_strategy(tasks, &validator) + .expect("Should build strategy with buffer optimization"); assert_eq!(strategy.optimized_tasks.len(), 1); assert_eq!( @@ -745,12 +633,8 @@ mod tests { }) .collect(); - let strategy = TaskStrategist::build_strategy( - tasks, - &validator, - &None::, - ) - .expect("Should build strategy with buffer optimization"); + let strategy = TaskStrategist::build_strategy(tasks, &validator) + .expect("Should build strategy with buffer optimization"); for optimized_task in strategy.optimized_tasks { assert!(matches!(optimized_task.strategy(), TaskStrategy::Buffer)); @@ -773,12 +657,8 @@ mod tests { }) .collect(); - let strategy = TaskStrategist::build_strategy( - tasks, - &validator, - &None::, - ) - .expect("Should build strategy with buffer optimization"); + let strategy = TaskStrategist::build_strategy(tasks, &validator) + .expect("Should build strategy with buffer optimization"); for optimized_task in strategy.optimized_tasks { assert!(matches!(optimized_task.strategy(), TaskStrategy::Buffer)); @@ -800,11 +680,7 @@ mod tests { }) .collect(); - let result = TaskStrategist::build_strategy( - tasks, - &validator, - &None::, - ); + let result = TaskStrategist::build_strategy(tasks, &validator); assert!(matches!(result, Err(TaskStrategistError::FailedToFitError))); } @@ -832,12 +708,8 @@ mod tests { create_test_undelegate_task().into(), ]; - let strategy = TaskStrategist::build_strategy( - tasks, - &validator, - &None::, - ) - .expect("Should build strategy"); + let strategy = TaskStrategist::build_strategy(tasks, &validator) + .expect("Should build strategy"); assert_eq!(strategy.optimized_tasks.len(), 4); @@ -868,13 +740,9 @@ mod tests { let intent = create_test_intent(0, &pubkey, false); let info_fetcher = Arc::new(MockInfoFetcher); - let commit_task = TaskBuilderImpl::commit_tasks( - &info_fetcher, - &intent, - &None::, - ) - .await - .unwrap(); + let commit_task = TaskBuilderImpl::commit_tasks(&info_fetcher, &intent) + .await + .unwrap(); let finalize_task = TaskBuilderImpl::finalize_tasks(&info_fetcher, &intent) .await @@ -884,7 +752,6 @@ mod tests { commit_task, finalize_task, &Pubkey::new_unique(), - &None::, ) .expect("Execution mode created"); @@ -900,13 +767,9 @@ mod tests { let intent = create_test_intent(0, &pubkeys, true); let info_fetcher = Arc::new(MockInfoFetcher); - let commit_task = TaskBuilderImpl::commit_tasks( - &info_fetcher, - &intent, - &None::, - ) - .await - .unwrap(); + let commit_task = TaskBuilderImpl::commit_tasks(&info_fetcher, &intent) + .await + .unwrap(); let finalize_task = TaskBuilderImpl::finalize_tasks(&info_fetcher, &intent) .await @@ -916,19 +779,14 @@ mod tests { commit_task, finalize_task, &Pubkey::new_unique(), - &None::, ) .expect("Execution mode created"); - let StrategyExecutionMode::TwoStage { - commit_stage, - finalize_stage, - } = execution_mode - else { + let StrategyExecutionMode::TwoStage(two_stage) = execution_mode else { panic!("Unexpected execution mode"); }; - assert!(!commit_stage.uses_alts()); - assert!(!finalize_stage.uses_alts()); + assert!(!two_stage.commit_stage.uses_alts()); + assert!(!two_stage.finalize_stage.uses_alts()); } #[tokio::test] @@ -937,13 +795,9 @@ mod tests { let intent = create_test_intent(0, &pubkeys, false); let info_fetcher = Arc::new(MockInfoFetcher); - let commit_task = TaskBuilderImpl::commit_tasks( - &info_fetcher, - &intent, - &None::, - ) - .await - .unwrap(); + let commit_task = TaskBuilderImpl::commit_tasks(&info_fetcher, &intent) + .await + .unwrap(); let finalize_task = TaskBuilderImpl::finalize_tasks(&info_fetcher, &intent) .await @@ -953,7 +807,6 @@ mod tests { commit_task, finalize_task, &Pubkey::new_unique(), - &None::, ) .expect("Execution mode created"); diff --git a/magicblock-committor-service/src/transaction_preparator/delivery_preparator.rs b/magicblock-committor-service/src/transaction_preparator/delivery_preparator.rs index f5d7a8c9e..8a3c920f0 100644 --- a/magicblock-committor-service/src/transaction_preparator/delivery_preparator.rs +++ b/magicblock-committor-service/src/transaction_preparator/delivery_preparator.rs @@ -28,12 +28,10 @@ use solana_transaction_error::TransactionError; use tracing::{error, info}; use crate::{ - persist::{CommitStatus, IntentPersister}, tasks::{ commit_task::CommitBufferStage, task_strategist::TransactionStrategy, BaseTaskImpl, CleanupTask, PreparationTask, }, - utils::persist_status_update, ComputeBudgetConfig, }; @@ -59,11 +57,10 @@ impl DeliveryPreparator { } /// Prepares buffers and necessary pieces for optimized TX - pub async fn prepare_for_delivery( + pub async fn prepare_for_delivery( &self, authority: &Keypair, strategy: &mut TransactionStrategy, - persister: &Option

, ) -> DeliveryPreparatorResult> { let preparation_futures = strategy.optimized_tasks.iter_mut().map(|task| async move { @@ -71,8 +68,7 @@ impl DeliveryPreparator { metrics::observe_committor_intent_task_preparation_time( &*task, ); - self.prepare_task_handling_errors(authority, task, persister) - .await + self.prepare_task_handling_errors(authority, task).await }); let task_preparations = join_all(preparation_futures); @@ -94,11 +90,10 @@ impl DeliveryPreparator { } /// Prepares necessary parts for TX if needed, otherwise returns immediately - pub async fn prepare_task( + pub async fn prepare_task( &self, authority: &Keypair, task: &mut BaseTaskImpl, - persister: &Option

, ) -> DeliveryPreparatorResult<(), InternalError> { let stage = match task { BaseTaskImpl::Commit(commit_task) => commit_task.stage_mut(), @@ -114,39 +109,13 @@ impl DeliveryPreparator { return Ok(()); }; - // Persist as failed until rewritten - let update_status = CommitStatus::BufferAndChunkPartiallyInitialized; - persist_status_update( - persister, - &preparation_task.pubkey, - preparation_task.commit_id, - update_status, - ); - // Initialize buffer account. Init + reallocs self.initialize_buffer_account(authority, preparation_task) .await?; - // Persist initialization success - let update_status = CommitStatus::BufferAndChunkInitialized; - persist_status_update( - persister, - &preparation_task.pubkey, - preparation_task.commit_id, - update_status, - ); - // Writing chunks with some retries self.write_buffer_with_retries(authority, preparation_task) .await?; - // Persist that buffer account initiated successfully - let update_status = CommitStatus::BufferAndChunkFullyInitialized; - persist_status_update( - persister, - &preparation_task.pubkey, - preparation_task.commit_id, - update_status, - ); let cleanup_task = preparation_task.cleanup_task(); *stage = CommitBufferStage::Cleanup(cleanup_task); @@ -155,13 +124,12 @@ impl DeliveryPreparator { /// Runs `prepare_task` and, if the buffer was already initialized, /// performs cleanup and retries once. - pub async fn prepare_task_handling_errors( + pub async fn prepare_task_handling_errors( &self, authority: &Keypair, task: &mut BaseTaskImpl, - persister: &Option

, ) -> Result<(), InternalError> { - let res = self.prepare_task(authority, task, persister).await; + let res = self.prepare_task(authority, task).await; match res { Err(InternalError::BufferExecutionError( BufferExecutionError::AccountAlreadyInitializedError( @@ -198,7 +166,7 @@ impl DeliveryPreparator { // Restore preparation stage for retry *stage = CommitBufferStage::Preparation(preparation_task); - self.prepare_task(authority, task, persister).await + self.prepare_task(authority, task).await } /// Initializes buffer account for future writes diff --git a/magicblock-committor-service/src/transaction_preparator/error.rs b/magicblock-committor-service/src/transaction_preparator/error.rs index f95c4dbbe..4efafe034 100644 --- a/magicblock-committor-service/src/transaction_preparator/error.rs +++ b/magicblock-committor-service/src/transaction_preparator/error.rs @@ -1,3 +1,4 @@ +use magicblock_rpc_client::MagicBlockRpcClientError; use solana_signature::Signature; use solana_signer::SignerError; use thiserror::Error; @@ -13,6 +14,8 @@ pub enum TransactionPreparatorError { FailedToFitError, #[error("SignerError: {0}")] SignerError(#[from] SignerError), + #[error("Failed to get latest blockhash: {0}")] + GetLatestBlockhashError(#[source] MagicBlockRpcClientError), #[error("DeliveryPreparationError: {0}")] DeliveryPreparationError(Box), } diff --git a/magicblock-committor-service/src/transaction_preparator/mod.rs b/magicblock-committor-service/src/transaction_preparator/mod.rs index cc60e955c..7ad0a4359 100644 --- a/magicblock-committor-service/src/transaction_preparator/mod.rs +++ b/magicblock-committor-service/src/transaction_preparator/mod.rs @@ -7,7 +7,6 @@ use solana_message::VersionedMessage; use solana_pubkey::Pubkey; use crate::{ - persist::IntentPersister, tasks::{ commit_task::CommitBufferStage, task_strategist::TransactionStrategy, utils::TransactionUtils, BaseTaskImpl, @@ -28,11 +27,10 @@ pub mod error; pub trait TransactionPreparator: Send + Sync + 'static { /// Return [`VersionedMessage`] corresponding to [`TransactionStrategy`] /// Handles all necessary preparation needed for successful [`BaseTask`] execution - async fn prepare_for_strategy( + async fn prepare_for_strategy( &self, authority: &Keypair, transaction_strategy: &mut TransactionStrategy, - intent_persister: &Option

, ) -> PreparatorResult; /// Cleans up after strategy. @@ -75,11 +73,10 @@ impl TransactionPreparatorImpl { #[async_trait] impl TransactionPreparator for TransactionPreparatorImpl { - async fn prepare_for_strategy( + async fn prepare_for_strategy( &self, authority: &Keypair, tx_strategy: &mut TransactionStrategy, - intent_persister: &Option

, ) -> PreparatorResult { // If message won't fit, there's no reason to prepare anything // Fail early @@ -99,7 +96,7 @@ impl TransactionPreparator for TransactionPreparatorImpl { // Pre tx preparations. Create buffer accs + lookup tables let lookup_tables = self .delivery_preparator - .prepare_for_delivery(authority, tx_strategy, intent_persister) + .prepare_for_delivery(authority, tx_strategy) .await?; metrics::observe_committor_intent_alt_count(lookup_tables.len()); diff --git a/magicblock-committor-service/src/transactions.rs b/magicblock-committor-service/src/transactions.rs deleted file mode 100644 index 2c25c7d42..000000000 --- a/magicblock-committor-service/src/transactions.rs +++ /dev/null @@ -1,12 +0,0 @@ -use solana_packet::PACKET_DATA_SIZE; -use solana_rpc_client::rpc_client::SerializableTransaction; - -/// Maximum serialized transaction size that can be sent over the wire. -pub(crate) const MAX_TRANSACTION_WIRE_SIZE: usize = PACKET_DATA_SIZE; - -pub fn serialized_transaction_size( - transaction: &impl SerializableTransaction, -) -> usize { - // SAFETY: runs on transactions we already serialize before sending. - usize::try_from(bincode::serialized_size(transaction).unwrap()).unwrap() -} diff --git a/magicblock-committor-service/src/utils.rs b/magicblock-committor-service/src/utils.rs index 51b2c18b2..46bc392b0 100644 --- a/magicblock-committor-service/src/utils.rs +++ b/magicblock-committor-service/src/utils.rs @@ -1,40 +1,25 @@ -use solana_pubkey::Pubkey; -use tracing::error; +// https://solana.com/docs/core/transactions#transaction-size -use crate::persist::{CommitStatus, IntentPersister}; +use magicblock_committor_program::{ + consts::MAX_INSTRUCTION_DATA_SIZE, + instruction::IX_WRITE_SIZE_WITHOUT_CHUNKS, +}; +use solana_packet::PACKET_DATA_SIZE; +use solana_rpc_client::rpc_client::SerializableTransaction; -pub(crate) fn persist_status_update( - persister: &Option

, - pubkey: &Pubkey, - commit_id: u64, - update_status: CommitStatus, -) { - let Some(persister) = persister else { - return; - }; - if let Err(err) = persister.update_status_by_message( - commit_id, - pubkey, - update_status.clone(), - ) { - error!(pubkey = %pubkey, error = ?err, "Failed to persist status"); - } -} +const BUDGET_SET_COMPUTE_UNIT_PRICE_BYTES: u16 = (1 + 8) * 8; +const BUDGET_SET_COMPUTE_UNIT_LIMIT_BYTES: u16 = (1 + 4) * 8; +/// The maximum size of a chunk that can be written as part of a single transaction +pub(crate) const MAX_WRITE_CHUNK_SIZE: u16 = MAX_INSTRUCTION_DATA_SIZE + - IX_WRITE_SIZE_WITHOUT_CHUNKS + - BUDGET_SET_COMPUTE_UNIT_PRICE_BYTES + - BUDGET_SET_COMPUTE_UNIT_LIMIT_BYTES; +/// Maximum serialized transaction size that can be sent over the wire. +pub(crate) const MAX_TRANSACTION_WIRE_SIZE: usize = PACKET_DATA_SIZE; -/// Persists update by message/intent id -pub(crate) fn persist_status_update_by_message_set( - persister: &P, - message_id: u64, - pubkeys: &[Pubkey], - update_status: CommitStatus, -) { - pubkeys.iter().for_each(|pubkey| { - if let Err(err) = persister.update_status_by_message( - message_id, - pubkey, - update_status.clone(), - ) { - error!(pubkey = %pubkey, error = ?err, "Failed to persist status"); - } - }); +pub fn serialized_transaction_size( + transaction: &impl SerializableTransaction, +) -> usize { + // SAFETY: runs on transactions we already serialize before sending. + usize::try_from(bincode::serialized_size(transaction).unwrap()).unwrap() } diff --git a/magicblock-core/Cargo.toml b/magicblock-core/Cargo.toml index fe621634b..e1cd89dff 100644 --- a/magicblock-core/Cargo.toml +++ b/magicblock-core/Cargo.toml @@ -17,7 +17,7 @@ bytes = { workspace = true } serde = { workspace = true, features = ["derive"] } solana-account = { workspace = true } -solana-account-decoder = { workspace = true } +solana-account-decoder = { workspace = true, features = ["agave-unstable-api"] } solana-clock = { workspace = true } solana-hash = { workspace = true } solana-message = { workspace = true } @@ -27,7 +27,7 @@ solana-signature = { workspace = true } solana-transaction = { workspace = true, features = ["blake3", "verify"] } solana-transaction-context = { workspace = true } solana-transaction-error = { workspace = true } -solana-transaction-status-client-types = { workspace = true } +solana-transaction-status-client-types = { workspace = true, features = ["agave-unstable-api"] } magicblock-magic-program-api = { workspace = true } spl-token = { workspace = true } spl-token-2022 = { workspace = true } diff --git a/magicblock-core/src/intent.rs b/magicblock-core/src/intent.rs index d66b0b1fd..8dd85dc19 100644 --- a/magicblock-core/src/intent.rs +++ b/magicblock-core/src/intent.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; use solana_account::{Account, AccountSharedData}; use solana_pubkey::Pubkey; +pub mod outbox; + use crate::token_programs::try_remap_ata_to_eata; pub type CommittedAccountRef = (Pubkey, AccountSharedData); diff --git a/magicblock-core/src/intent/outbox.rs b/magicblock-core/src/intent/outbox.rs new file mode 100644 index 000000000..c6529f8d3 --- /dev/null +++ b/magicblock-core/src/intent/outbox.rs @@ -0,0 +1,15 @@ +use solana_pubkey::Pubkey; + +pub const OUTBOX_INTENT_SEED: &[u8] = b"outbox-intent"; + +pub const OUTBOX_INTENT_DISCRIMINATOR: [u8; 8] = *b"obintent"; + +/// Derives the outbox intent PDA for a given intent ID. +/// Seeds: `["outbox-intent", intent_id.to_le_bytes()]` +pub fn outbox_intent_pda(id: u64) -> Pubkey { + Pubkey::find_program_address( + &[OUTBOX_INTENT_SEED, &id.to_le_bytes()], + &magicblock_magic_program_api::ID, + ) + .0 +} diff --git a/magicblock-magic-program-api/Cargo.toml b/magicblock-magic-program-api/Cargo.toml index 4c6358131..d8d62fef2 100644 --- a/magicblock-magic-program-api/Cargo.toml +++ b/magicblock-magic-program-api/Cargo.toml @@ -18,6 +18,7 @@ backward-compat = [ [dependencies] solana-program = { workspace = true } solana-program-compat = { package = "solana-program", version = ">=2.0, <3", default-features = false, optional = true } +solana-hash = { workspace = true, features = ["serde"] } solana-signature = { workspace = true, features = ["serde"] } solana-signature-compat = { package = "solana-signature", version = ">=2.0, <3", default-features = false, features = ["serde"], optional = true } bincode = "^1.3.3" diff --git a/magicblock-magic-program-api/src/instruction.rs b/magicblock-magic-program-api/src/instruction.rs index 208db91f6..ec958181b 100644 --- a/magicblock-magic-program-api/src/instruction.rs +++ b/magicblock-magic-program-api/src/instruction.rs @@ -8,6 +8,7 @@ use crate::{ ScheduleTaskArgs, }, compat::Instruction, + outbox::ExecutionStage, Pubkey, }; @@ -57,27 +58,34 @@ pub enum MagicBlockInstruction { /// - **2..n** `[]` Accounts to be committed and undelegated ScheduleCommitAndUndelegate, - /// Moves the scheduled commit from the MagicContext to the global scheduled commits - /// map. This is the second part of scheduling a commit. + /// Pops up to N intents from the front of `MagicContext.scheduled_base_intents` + /// and for each creates an outbox intent ephemeral account (`MagicIntentAccount`) + /// with `status = Accepted`. This is the second part of scheduling a commit. /// - /// It is run at the start of the slot to update the global scheduled commits map just - /// in time for the validator to realize the commits right after. + /// N is determined by the number of writable PDA accounts provided beyond + /// the four fixed accounts. It is run at the start of the slot. /// /// # Account references - /// - **0.** `[SIGNER]` Validator Authority - /// - **1.** `[WRITE]` Magic Context Account containing the initially scheduled commits + /// - **0.** `[WRITE, SIGNER]` Validator Authority + /// - **1.** `[]` Magic Program + /// - **2.** `[WRITE]` Magic Context Account containing the initially scheduled commits + /// - **3.** `[WRITE]` Ephemeral Vault + /// - **4..n** `[WRITE]` Outbox intent PDAs, one per accepted intent, seeds: `["outbox-intent", intent_id.to_le_bytes()]` AcceptScheduleCommits, /// Records the attempt to realize a scheduled commit on chain. + /// Closes the associated outbox intent PDA account. /// /// The signature of this transaction can be pre-calculated since we pass the /// ID of the scheduled commit and retrieve the signature from a globally - /// stored hashmap. + /// stored hashmap. Transaction uniqueness is guaranteed by the per-intent PDA. /// - /// We implement it this way so we can log the signature of this transaction - /// as part of the [MagicBlockInstruction::ScheduleCommit] instruction. - /// Args: (intent_id, bump) - bump is needed in order to guarantee unique transactions - ScheduledCommitSent((u64, u64)), + /// # Account references + /// - **0.** `[WRITE, SIGNER]` Validator Authority (receives rent refund from close) + /// - **1.** `[]` Magic Program + /// - **2.** `[WRITE]` Ephemeral Vault + /// - **3.** `[WRITE]` Outbox intent PDA to close, seeds: `["outbox-intent", intent_id.to_le_bytes()]` + ScheduledCommitSent(u64), /// Schedules execution of a single *base intent*. /// @@ -311,6 +319,17 @@ pub enum MagicBlockInstruction { authority: Pubkey, instructions: Vec, }, + + /// Sets or advances the execution stage of an outbox intent. + /// Must be called before sending the L1 transaction. + /// + /// # Account references + /// - **0.** `[SIGNER]` Validator Authority + /// - **1.** `[WRITE]` Outbox intent PDA, seeds: `["outbox-intent", intent_id.to_le_bytes()]` + SetIntentExecutionStage { + intent_id: u64, + stage: ExecutionStage, + }, } impl MagicBlockInstruction { diff --git a/magicblock-magic-program-api/src/lib.rs b/magicblock-magic-program-api/src/lib.rs index 25b614b7e..506ae7ccb 100644 --- a/magicblock-magic-program-api/src/lib.rs +++ b/magicblock-magic-program-api/src/lib.rs @@ -1,6 +1,7 @@ pub mod args; pub mod compat; pub mod instruction; +pub mod outbox; pub mod pda; pub mod response; diff --git a/magicblock-magic-program-api/src/outbox.rs b/magicblock-magic-program-api/src/outbox.rs new file mode 100644 index 000000000..5cce7f1c6 --- /dev/null +++ b/magicblock-magic-program-api/src/outbox.rs @@ -0,0 +1,136 @@ +use serde::{Deserialize, Serialize}; +use solana_hash::Hash; +use solana_signature::Signature; + +/// A transaction that was sent but not yet confirmed, along with the +/// blockhash it was built with. The blockhash is needed on recovery to +/// tell apart "may still land" (blockhash still valid) from "guaranteed +/// dead" (blockhash expired) when the signature isn't found on-chain. +#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct PendingTransaction { + pub signature: Signature, + pub blockhash: Hash, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum ExecutionStage { + SingleStage(PendingTransaction), + TwoStage(TwoStageProgress), +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub enum TwoStageProgress { + Committing(PendingTransaction), + Finalizing { + commit: Signature, + finalize: PendingTransaction, + }, +} + +impl ExecutionStage { + pub fn apply_stage_transition( + &mut self, + stage: ExecutionStage, + ) -> Result<(), &'static str> { + match (self, stage) { + // Current sig wasn't confirmed, we replace it with new attempt + (Self::SingleStage(ref mut this_sig), Self::SingleStage(sig)) => { + *this_sig = sig; + } + // TODO(edwin): validate this case, + // We tried SingleStage execution, but failed (CpiLimit, etc) + // We patch it moving to TwoStage flow + ( + this @ Self::SingleStage(_), + val @ Self::TwoStage(TwoStageProgress::Committing(_)), + ) => { + *this = val; + } + // Only transition to TwoStageProgress::Committing is valid from SingleStage + ( + Self::SingleStage(_), + Self::TwoStage(TwoStageProgress::Finalizing { .. }), + ) => { + return Err("cannot transition from SingleStage to Finalizing"); + } + // Transitions within TwoStage states + (Self::TwoStage(ref mut this), Self::TwoStage(value)) => { + this.apply_stage_transition(value)?; + } + // TwoStage can't be downgraded into SingleStage + (Self::TwoStage(_), Self::SingleStage(_)) => { + return Err( + "cannot change execution type from TwoStage to SingleStage", + ); + } + } + + Ok(()) + } + + pub fn pending_transaction(&self) -> &PendingTransaction { + match self { + Self::SingleStage(pending) => pending, + Self::TwoStage(value) => value.pending_transaction(), + } + } +} + +impl TwoStageProgress { + fn apply_stage_transition( + &mut self, + stage: TwoStageProgress, + ) -> Result<(), &'static str> { + let new_state = match (&self, stage) { + // Current sig didn't succeed on Base, we replace it with new attempt + (Self::Committing(_), Self::Committing(new_sig)) => { + Self::Committing(new_sig) + } + // Commit was successfully executed and now we move on to Finalizing + ( + Self::Committing(this_pending), + Self::Finalizing { commit, finalize }, + ) => { + if this_pending.signature != commit { + return Err( + "commit signature mismatch on advance to Finalizing", + ); + } + + Self::Finalizing { commit, finalize } + } + // Current finalize sig wasn't confirmed, we replace it with new attempt + ( + Self::Finalizing { + commit: this_commit, + .. + }, + Self::Finalizing { commit, finalize }, + ) => { + if this_commit != &commit { + return Err( + "commit signature can't be replaced in Finalize stage", + ); + } + + Self::Finalizing { commit, finalize } + } + // Incorrect state transition + (Self::Finalizing { .. }, Self::Committing(_)) => { + return Err( + "downgrade from Finalizing to Committing not permitted", + ); + } + }; + + *self = new_state; + Ok(()) + } + + pub fn pending_transaction(&self) -> &PendingTransaction { + match self { + Self::Committing(pending) => pending, + Self::Finalizing { finalize, .. } => finalize, + } + } +} diff --git a/magicblock-metrics/src/metrics/mod.rs b/magicblock-metrics/src/metrics/mod.rs index 11b7f2ad9..63bf35966 100644 --- a/magicblock-metrics/src/metrics/mod.rs +++ b/magicblock-metrics/src/metrics/mod.rs @@ -539,6 +539,14 @@ lazy_static::lazy_static! { "Number of signatures included in batched getSignatureStatuses requests" ).unwrap(); + static ref RPC_CLIENT_SIGNATURE_HISTORY_DURATION_SECONDS: Histogram = Histogram::with_opts( + HistogramOpts::new( + "rpc_client_signature_history_duration_seconds", + "Time in seconds spent on getSignatureStatuses-with-history (restart recovery)" + ) + .buckets(vec![0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0]) + ).unwrap(); + static ref COMMITTOR_INTENT_TASK_PREPARATION_TIME: HistogramVec = HistogramVec::new( HistogramOpts::new( @@ -780,6 +788,7 @@ pub(crate) fn register() { register!(RPC_CLIENT_SIGNATURE_WS_FALLBACK_COUNT); register!(RPC_CLIENT_SIGNATURE_STATUS_BATCH_COUNT); register!(RPC_CLIENT_SIGNATURE_STATUS_BATCH_SIGNATURES_COUNT); + register!(RPC_CLIENT_SIGNATURE_HISTORY_DURATION_SECONDS); register!(CONNECTED_PUBSUB_CLIENTS_GAUGE); register!(CONNECTED_DIRECT_PUBSUB_CLIENTS_GAUGE); register!(PUBSUB_CLIENT_UPTIME_GAUGE); @@ -1289,6 +1298,10 @@ pub fn inc_rpc_client_signature_status_batch_signatures_count(count: u64) { RPC_CLIENT_SIGNATURE_STATUS_BATCH_SIGNATURES_COUNT.inc_by(count) } +pub fn start_rpc_client_signature_history_timer() -> HistogramTimer { + RPC_CLIENT_SIGNATURE_HISTORY_DURATION_SECONDS.start_timer() +} + pub fn set_connected_pubsub_clients_count(count: usize) { CONNECTED_PUBSUB_CLIENTS_GAUGE.set(count as i64); } diff --git a/magicblock-rpc-client/src/lib.rs b/magicblock-rpc-client/src/lib.rs index 02d2016f0..76499e08c 100644 --- a/magicblock-rpc-client/src/lib.rs +++ b/magicblock-rpc-client/src/lib.rs @@ -110,6 +110,27 @@ impl MagicBlockRpcClientError { _ => None, } } + + pub fn is_transaction_too_large(&self) -> bool { + fn is_send_transaction_too_large(err: &RpcClientError) -> bool { + matches!(err.request, Some(RpcRequest::SendTransaction)) + && matches!( + &*err.kind, + RpcClientErrorKind::RpcError( + RpcError::RpcResponseError { code: -32602, message, .. } + ) if message.contains("VersionedTransaction too large") + || message.contains("base64 encoded too large") + ) + } + + match self { + MagicBlockRpcClientError::RpcClientError(err) + | MagicBlockRpcClientError::SendTransaction(err) => { + is_send_transaction_too_large(err) + } + _ => false, + } + } } pub type MagicBlockRpcClientResult = diff --git a/magicblock-rpc-client/src/utils.rs b/magicblock-rpc-client/src/utils.rs index 93acbb6e8..4fe44415d 100644 --- a/magicblock-rpc-client/src/utils.rs +++ b/magicblock-rpc-client/src/utils.rs @@ -33,7 +33,7 @@ pub trait SendErrorMapper { /// - `ExecErr`: The unified execution error type returned to the caller with mapped errors pub async fn send_transaction_with_retries( make_send_fut: F, - send_result_mapper: Map, + send_error_mapper: Map, stop_predicate: impl Fn(usize, Duration) -> bool, ) -> Result where @@ -56,7 +56,42 @@ where }, Err(err) => err, }; - let mapped_error = send_result_mapper.map(err); + let mapped_error = send_error_mapper.map(err); + let sleep_duration = match Map::decide_flow(&mapped_error) { + ControlFlow::Continue(value) => value, + ControlFlow::Break(()) => return Err(mapped_error), + }; + + if stop_predicate(i, start.elapsed()) { + return Err(mapped_error); + } + + sleep(sleep_duration).await + } +} + +pub async fn get_with_retries( + make_get_fut: F, + error_mapper: Map, + stop_predicate: impl Fn(usize, Duration) -> bool, +) -> Result +where + F: Fn() -> Fut, + Fut: Future>, + Map: SendErrorMapper, +{ + let start = Instant::now(); + let mut i = 0; + + loop { + i += 1; + + let result = make_get_fut().await; + let err = match result { + Ok(value) => return Ok(value), + Err(err) => err, + }; + let mapped_error = error_mapper.map(err); let sleep_duration = match Map::decide_flow(&mapped_error) { ControlFlow::Continue(value) => value, ControlFlow::Break(()) => return Err(mapped_error), diff --git a/programs/magicblock/src/magic_scheduled_base_intent.rs b/programs/magicblock/src/intent_bundles/magic_scheduled_base_intent.rs similarity index 98% rename from programs/magicblock/src/magic_scheduled_base_intent.rs rename to programs/magicblock/src/intent_bundles/magic_scheduled_base_intent.rs index 88050bf62..9d585601f 100644 --- a/programs/magicblock/src/magic_scheduled_base_intent.rs +++ b/programs/magicblock/src/intent_bundles/magic_scheduled_base_intent.rs @@ -182,6 +182,20 @@ impl ScheduledIntentBundle { self.intent_bundle.get_undelegate_intent_pubkeys() } + /// Returns pubkeys of accounts whose commit nonce becomes stale once + /// undelegated, i.e. those undelegated directly or via commit-finalize. + pub fn get_undelegated_pubkeys(&self) -> Vec { + self.intent_bundle + .get_undelegate_intent_pubkeys() + .into_iter() + .chain( + self.intent_bundle + .get_commit_finalize_and_undelegate_intent_pubkeys(), + ) + .flatten() + .collect() + } + pub fn has_undelegate_intent(&self) -> bool { self.intent_bundle.has_undelegate_intent() } diff --git a/programs/magicblock/src/intent_bundles/mod.rs b/programs/magicblock/src/intent_bundles/mod.rs new file mode 100644 index 000000000..01c902946 --- /dev/null +++ b/programs/magicblock/src/intent_bundles/mod.rs @@ -0,0 +1,5 @@ +pub mod magic_scheduled_base_intent; +pub mod outbox; +pub mod outbox_intent_bundles; +mod process_execute_callback; +pub mod schedule; diff --git a/programs/magicblock/src/intent_bundles/outbox/mod.rs b/programs/magicblock/src/intent_bundles/outbox/mod.rs new file mode 100644 index 000000000..5f5624246 --- /dev/null +++ b/programs/magicblock/src/intent_bundles/outbox/mod.rs @@ -0,0 +1,3 @@ +pub mod process_accept_scheduled_commits; +pub mod process_scheduled_commit_sent; +pub mod process_set_intent_execution_stage; diff --git a/programs/magicblock/src/intent_bundles/outbox/process_accept_scheduled_commits.rs b/programs/magicblock/src/intent_bundles/outbox/process_accept_scheduled_commits.rs new file mode 100644 index 000000000..a650bd572 --- /dev/null +++ b/programs/magicblock/src/intent_bundles/outbox/process_accept_scheduled_commits.rs @@ -0,0 +1,266 @@ +use std::collections::HashSet; + +use magicblock_core::intent::outbox::outbox_intent_pda; +use magicblock_magic_program_api::{ + instruction::MagicBlockInstruction, EPHEMERAL_VAULT_PUBKEY, +}; +use solana_account::{ReadableAccount, WritableAccount}; +use solana_instruction::{error::InstructionError, AccountMeta, Instruction}; +use solana_log_collector::ic_msg; +use solana_program_runtime::invoke_context::InvokeContext; +use solana_pubkey::Pubkey; + +use crate::{ + intent_bundles::outbox_intent_bundles::OutboxIntentBundle, + magic_scheduled_base_intent::ScheduledIntentBundle, + schedule_transactions, + utils::accounts::{ + get_instruction_account_with_idx, get_instruction_pubkey_with_idx, + }, + validator::effective_validator_authority_id, + MagicContext, +}; + +const VALIDATOR_AUTHORITY_IDX: u16 = 0; +const MAGIC_PROGRAM_ID: u16 = VALIDATOR_AUTHORITY_IDX + 1; +const MAGIC_CONTEXT_IDX: u16 = MAGIC_PROGRAM_ID + 1; +const VAULT_IDX: u16 = MAGIC_CONTEXT_IDX + 1; +const INTENT_PDAS_OFFSET: u16 = VAULT_IDX + 1; + +pub fn process_accept_scheduled_commits( + signers: HashSet, + invoke_context: &mut InvokeContext, +) -> Result<(), InstructionError> { + // Common conditions verification + let validator_auth = effective_validator_authority_id(); + validate(&signers, invoke_context, &validator_auth)?; + + // pop first n intents + // n - is number of OutboxIntentBundle PDAs passed + let intents = pop_scheduled_intents(invoke_context)?; + if intents.is_empty() { + // NOTE: we should have not been called if no commits are scheduled + ic_msg!( + invoke_context, + "AcceptScheduledCommits ERR: no scheduled commits to accept" + ); + return Ok(()); + } + + for (i, intent) in intents.into_iter().enumerate() { + let pda_idx = INTENT_PDAS_OFFSET + i as u16; + let pda = verify_intent_pda(invoke_context, intent.id, pda_idx)?; + + // Create outbox ephemeral account + create_outbox_account_cpi( + invoke_context, + validator_auth, + pda, + OutboxIntentBundle::accepted(intent), + )?; + } + + Ok(()) +} + +fn validate( + signers: &HashSet, + invoke_context: &InvokeContext, + validator_auth: &Pubkey, +) -> Result<(), InstructionError> { + // Check magic context + schedule_transactions::check_magic_context_id( + invoke_context, + MAGIC_CONTEXT_IDX, + )?; + + let transaction_context = &*invoke_context.transaction_context; + + // Assert magic program account + let magic_program_pubkey = + get_instruction_pubkey_with_idx(transaction_context, MAGIC_PROGRAM_ID)?; + if *magic_program_pubkey != crate::id() { + ic_msg!( + invoke_context, + "AcceptScheduledCommits ERR: account at idx {} is {}, expected magic program {}", + MAGIC_PROGRAM_ID, + magic_program_pubkey, + crate::id() + ); + return Err(InstructionError::IncorrectProgramId); + } + + // Assert validator authority + let provided_validator_auth = get_instruction_pubkey_with_idx( + transaction_context, + VALIDATOR_AUTHORITY_IDX, + )?; + if provided_validator_auth != validator_auth { + ic_msg!( + invoke_context, + "AcceptScheduledCommits ERR: invalid validator authority {}, should be {}", + provided_validator_auth, + validator_auth + ); + return Err(InstructionError::InvalidArgument); + } + + // Validate authority is a signer + if !signers.contains(validator_auth) { + ic_msg!( + invoke_context, + "AcceptScheduledCommits ERR: validator authority pubkey {} not in signers", + validator_auth + ); + return Err(InstructionError::MissingRequiredSignature); + } + + Ok(()) +} + +fn verify_intent_pda( + invoke_context: &InvokeContext, + intent_id: u64, + pda_idx: u16, +) -> Result { + let transaction_context = &*invoke_context.transaction_context; + let provided = + get_instruction_pubkey_with_idx(transaction_context, pda_idx)?; + let expected = outbox_intent_pda(intent_id); + if *provided != expected { + ic_msg!( + invoke_context, + "AcceptScheduledCommits ERR: account at idx {} is {}, expected PDA {} for intent {}", + pda_idx, + provided, + expected, + intent_id + ); + return Err(InstructionError::InvalidArgument); + } + Ok(expected) +} + +fn pop_scheduled_intents( + invoke_context: &InvokeContext, +) -> Result, InstructionError> { + let transaction_context = &*invoke_context.transaction_context; + let num_ix_accounts = transaction_context + .get_current_instruction_context()? + .get_number_of_instruction_accounts() + as usize; + + // Assert enough accounts + let num_accept_intents = match num_ix_accounts + .checked_sub(INTENT_PDAS_OFFSET as usize) + { + Some(0) => { + // No outbox intent PDAs provided - nothing to accept + return Ok(vec![]); + } + Some(count) => count, + None => { + ic_msg!( + invoke_context, + "AcceptScheduledCommits ERR: not enough accounts to accept intents ({}), need validator authority, magic context, vault, and at least one outbox intent PDA", + num_ix_accounts + ); + return Err(InstructionError::MissingAccount); + } + }; + + let magic_context_acc = get_instruction_account_with_idx( + transaction_context, + MAGIC_CONTEXT_IDX, + )?; + let mut magic_context = MagicContext::deserialize( + magic_context_acc.borrow()?.data(), + ) + .map_err(|err| { + ic_msg!( + invoke_context, + "Failed to deserialize MagicContext: {}", + err + ); + InstructionError::InvalidAccountData + })?; + + let intents = + magic_context.take_front_scheduled_commits(num_accept_intents); + if intents.len() != num_accept_intents { + ic_msg!( + invoke_context, + "AcceptScheduledCommits ERR: requested {} intents but only {} available", + num_accept_intents, + intents.len() + ); + + return Err(InstructionError::InvalidArgument); + } + + // Write updated account data + magic_context + .write_to(magic_context_acc.borrow_mut()?.data_as_mut_slice())?; + + Ok(intents) +} + +fn create_outbox_account_cpi( + invoke_context: &mut InvokeContext, + validator_auth: Pubkey, + pda: Pubkey, + outbox_account: OutboxIntentBundle, +) -> Result<(), InstructionError> { + let intent_id = outbox_account.inner.id; + let data = outbox_account.try_to_bytes().map_err(|_| { + ic_msg!( + invoke_context, + "AcceptScheduledCommits ERR: failed to serialize intent {}", + intent_id + ); + InstructionError::InvalidAccountData + })?; + + create_ephemeral_account_cpi( + invoke_context, + validator_auth, + pda, + data.len() as u32, + )?; + + // Move intent data in new account + let transaction_context = &*invoke_context.transaction_context; + let tx_idx = transaction_context + .find_index_of_account(&pda) + .ok_or(InstructionError::MissingAccount)?; + transaction_context + .accounts() + .try_borrow_mut(tx_idx) + .map_err(|_| InstructionError::AccountBorrowFailed)? + .data_as_mut_slice() + .copy_from_slice(&data); + + Ok(()) +} + +fn create_ephemeral_account_cpi( + invoke_context: &mut InvokeContext, + sponsor: Pubkey, + pda: Pubkey, + data_len: u32, +) -> Result<(), InstructionError> { + invoke_context.native_invoke( + Instruction { + program_id: crate::id(), + accounts: vec![ + AccountMeta::new(sponsor, true), + AccountMeta::new(pda, true), + AccountMeta::new(EPHEMERAL_VAULT_PUBKEY, false), + ], + data: MagicBlockInstruction::CreateEphemeralAccount { data_len } + .try_to_vec() + .map_err(|_| InstructionError::InvalidInstructionData)?, + }, + &[pda], + ) +} diff --git a/programs/magicblock/src/schedule_transactions/process_scheduled_commit_sent.rs b/programs/magicblock/src/intent_bundles/outbox/process_scheduled_commit_sent.rs similarity index 74% rename from programs/magicblock/src/schedule_transactions/process_scheduled_commit_sent.rs rename to programs/magicblock/src/intent_bundles/outbox/process_scheduled_commit_sent.rs index e42338c07..a3ceb86b1 100644 --- a/programs/magicblock/src/schedule_transactions/process_scheduled_commit_sent.rs +++ b/programs/magicblock/src/intent_bundles/outbox/process_scheduled_commit_sent.rs @@ -4,15 +4,17 @@ use std::{ }; use lazy_static::lazy_static; -use magicblock_core::coordination_mode; +use magicblock_core::{coordination_mode, intent::outbox::outbox_intent_pda}; +use magicblock_magic_program_api::{ + instruction::MagicBlockInstruction, EPHEMERAL_VAULT_PUBKEY, +}; use solana_clock::Slot; use solana_hash::Hash; -use solana_instruction::error::InstructionError; +use solana_instruction::{error::InstructionError, AccountMeta, Instruction}; use solana_log_collector::ic_msg; use solana_program_runtime::invoke_context::InvokeContext; use solana_pubkey::Pubkey; use solana_signature::Signature; -use solana_transaction_context::TransactionContext; use crate::{ errors::custom_error_codes, @@ -114,9 +116,8 @@ fn get_scheduled_commit(id: u64) -> Option { pub fn process_scheduled_commit_sent( signers: HashSet, - invoke_context: &InvokeContext, - transaction_context: &TransactionContext, - commit_id: u64, + invoke_context: &mut InvokeContext, + intent_id: u64, ) -> Result<(), InstructionError> { let mode = coordination_mode::CoordinationMode::current(); if !mode.should_schedule_intents() { @@ -128,19 +129,73 @@ pub fn process_scheduled_commit_sent( return Ok(()); } - const PROGRAM_IDX: u16 = 0; - const VALIDATOR_IDX: u16 = 1; + let (validator_authority_id, expected_pda) = + validate(&signers, invoke_context, intent_id)?; + + // Only after we passed all checks do we remove the commit from the global hashmap + // Otherwise a malicious actor could remove a commit from the hashmap without + // signing as the validator + let commit = match SENT_COMMITS.write() { + Ok(mut commits) => match commits.remove(&intent_id) { + Some(commit) => commit, + None => { + ic_msg!( + invoke_context, + "ScheduleCommitSent ERR: commit with id {} not found", + intent_id + ); + return Err(InstructionError::Custom( + custom_error_codes::CANNOT_FIND_SCHEDULED_COMMIT, + )); + } + }, + Err(err) => { + ic_msg!( + invoke_context, + "ScheduleCommitSent ERR: failed to lock SENT_COMMITS: {}", + err + ); + return Err(InstructionError::Custom( + custom_error_codes::UNABLE_TO_UNLOCK_SENT_COMMITS, + )); + } + }; + + // Log data + log_sent_commit(invoke_context, &commit); + commit + .error_message + .map(|_| Err(InstructionError::Custom(INTENT_FAILED_CODE))) + .unwrap_or(Ok(()))?; + + // Close Outbox intent + close_outbox_account_cpi( + invoke_context, + validator_authority_id, + expected_pda, + ) +} + +fn validate( + signers: &HashSet, + invoke_context: &InvokeContext, + intent_id: u64, +) -> Result<(Pubkey, Pubkey), InstructionError> { + const VALIDATOR_IDX: u16 = 0; + const MAGIC_PROGRAM_IDX: u16 = VALIDATOR_IDX + 1; + const MAGIC_VAULT_IDX: u16 = MAGIC_PROGRAM_IDX + 1; + const CLOSING_PDA_IDX: u16 = MAGIC_VAULT_IDX + 1; + + let transaction_context = &invoke_context.transaction_context; + let ix_ctx = transaction_context.get_current_instruction_context()?; // Assert MagicBlock program - let program_id = - get_instruction_pubkey_with_idx(transaction_context, PROGRAM_IDX)?; - if program_id.ne(&crate::id()) { + if ix_ctx.get_program_key()? != &crate::id() { ic_msg!( invoke_context, - "ScheduleCommitSent ERR: Invalid program id '{}'", - program_id + "ScheduleCommitSent ERR: Magic program account not found" ); - return Err(InstructionError::IncorrectProgramId); + return Err(InstructionError::UnsupportedProgramId); } // Assert validator identity matches @@ -156,6 +211,22 @@ pub fn process_scheduled_commit_sent( return Err(InstructionError::IncorrectAuthority); } + // Assert magic program account + let magic_program_pubkey = get_instruction_pubkey_with_idx( + transaction_context, + MAGIC_PROGRAM_IDX, + )?; + if *magic_program_pubkey != crate::id() { + ic_msg!( + invoke_context, + "ScheduleCommitSent ERR: account at idx {} is {}, expected magic program {}", + MAGIC_PROGRAM_IDX, + magic_program_pubkey, + crate::id() + ); + return Err(InstructionError::IncorrectProgramId); + } + // Assert signers if !signers.contains(&validator_authority_id) { ic_msg!( @@ -165,35 +236,29 @@ pub fn process_scheduled_commit_sent( return Err(InstructionError::MissingRequiredSignature); } - // Only after we passed all checks do we remove the commit from the global hashmap - // Otherwise a malicious actor could remove a commit from the hashmap without - // signing as the validator - let commit = match SENT_COMMITS.write() { - Ok(mut commits) => match commits.remove(&commit_id) { - Some(commit) => commit, - None => { - ic_msg!( - invoke_context, - "ScheduleCommitSent ERR: commit with id {} not found", - commit_id - ); - return Err(InstructionError::Custom( - custom_error_codes::CANNOT_FIND_SCHEDULED_COMMIT, - )); - } - }, - Err(err) => { - ic_msg!( - invoke_context, - "ScheduleCommitSent ERR: failed to lock SENT_COMMITS: {}", - err - ); - return Err(InstructionError::Custom( - custom_error_codes::UNABLE_TO_UNLOCK_SENT_COMMITS, - )); - } - }; + // Validate outbox intent PDA + let provided_pda = + get_instruction_pubkey_with_idx(transaction_context, CLOSING_PDA_IDX)?; + let expected_pda = outbox_intent_pda(intent_id); + if *provided_pda != expected_pda { + ic_msg!( + invoke_context, + "ScheduleCommitSent ERR: account at idx {} is {}, expected PDA {} for intent {}", + CLOSING_PDA_IDX, + provided_pda, + expected_pda, + intent_id + ); + return Err(InstructionError::InvalidArgument); + } + Ok((validator_authority_id, expected_pda)) +} + +fn log_sent_commit( + invoke_context: &InvokeContext, + commit: &SentCommitPrintable, +) { ic_msg!( invoke_context, "ScheduledCommitSent id: {}, slot: {}, blockhash: {}", @@ -201,13 +266,11 @@ pub fn process_scheduled_commit_sent( commit.slot, commit.blockhash, ); - ic_msg!( invoke_context, "ScheduledCommitSent payer: {}", commit.payer ); - ic_msg!( invoke_context, "ScheduledCommitSent included: [{}]", @@ -226,11 +289,9 @@ pub fn process_scheduled_commit_sent( sig ); } - if commit.requested_undelegation { - ic_msg!(invoke_context, "ScheduledCommitSent requested undelegation",); + ic_msg!(invoke_context, "ScheduledCommitSent requested undelegation"); } - for (idx, error) in commit.patched_errors.iter().enumerate() { ic_msg!( invoke_context, @@ -239,7 +300,6 @@ pub fn process_scheduled_commit_sent( error ); } - for (idx, report) in commit.callbacks_scheduling_results.iter().enumerate() { ic_msg!( @@ -249,20 +309,36 @@ pub fn process_scheduled_commit_sent( report ); } - - if let Some(error_message) = commit.error_message { + if let Some(error_message) = &commit.error_message { ic_msg!( invoke_context, "ScheduledCommitSent error message: {}", error_message ); - - Err(InstructionError::Custom(INTENT_FAILED_CODE)) - } else { - Ok(()) } } +fn close_outbox_account_cpi( + invoke_context: &mut InvokeContext, + sponsor: Pubkey, + pda: Pubkey, +) -> Result<(), InstructionError> { + invoke_context.native_invoke( + Instruction { + program_id: crate::id(), + accounts: vec![ + AccountMeta::new(sponsor, true), + AccountMeta::new(pda, false), + AccountMeta::new(EPHEMERAL_VAULT_PUBKEY, false), + ], + data: MagicBlockInstruction::CloseEphemeralAccount + .try_to_vec() + .map_err(|_| InstructionError::InvalidInstructionData)?, + }, + &[], + ) +} + #[cfg(test)] mod tests { use solana_account::AccountSharedData; @@ -275,7 +351,6 @@ mod tests { use crate::{ instruction_utils::InstructionUtils, test_utils::{ensure_started_validator, process_instruction}, - validator, }; fn single_acc_commit(commit_id: u64) -> SentCommit { @@ -328,11 +403,9 @@ mod tests { ensure_started_validator(&mut account_data, None); let mut ix = InstructionUtils::scheduled_commit_sent_instruction( - &crate::id(), - &validator::validator_authority_id(), commit.message_id, ); - ix.accounts[1].is_signer = false; + ix.accounts[0].is_signer = false; let transaction_accounts = transaction_accounts_from_map(&ix, &mut account_data); @@ -364,11 +437,10 @@ mod tests { }; ensure_started_validator(&mut account_data, None); - let ix = InstructionUtils::scheduled_commit_sent_instruction( - &crate::id(), - &fake_validator.pubkey(), + let mut ix = InstructionUtils::scheduled_commit_sent_instruction( commit.message_id, ); + ix.accounts[0].pubkey = fake_validator.pubkey(); let transaction_accounts = transaction_accounts_from_map(&ix, &mut account_data); process_instruction( @@ -399,11 +471,10 @@ mod tests { }; ensure_started_validator(&mut account_data, None); - let ix = InstructionUtils::scheduled_commit_sent_instruction( - &fake_program.pubkey(), - &validator::validator_authority_id(), + let mut ix = InstructionUtils::scheduled_commit_sent_instruction( commit.message_id, ); + ix.accounts[1].pubkey = fake_program.pubkey(); let transaction_accounts = transaction_accounts_from_map(&ix, &mut account_data); @@ -424,13 +495,24 @@ mod tests { fn test_registered_all_checks_out() { let commit = setup_registered_commit(); - let mut account_data = HashMap::new(); + let pda = outbox_intent_pda(commit.message_id); + let mut pda_account = AccountSharedData::new(0, 0, &crate::id()); + pda_account.set_ephemeral(true); + + let mut account_data = { + let mut map = HashMap::new(); + // Pre-fund vault so CloseEphemeralAccount CPI can refund sponsor + let mut vault = AccountSharedData::new(10_000, 0, &crate::id()); + vault.set_ephemeral(true); + map.insert(EPHEMERAL_VAULT_PUBKEY, vault); + // Add outbox PDA as existing ephemeral account (created by accept) + map.insert(pda, pda_account); + map + }; ensure_started_validator(&mut account_data, None); let ix = InstructionUtils::scheduled_commit_sent_instruction( - &crate::id(), - &validator::validator_authority_id(), commit.message_id, ); diff --git a/programs/magicblock/src/intent_bundles/outbox/process_set_intent_execution_stage.rs b/programs/magicblock/src/intent_bundles/outbox/process_set_intent_execution_stage.rs new file mode 100644 index 000000000..340cd771b --- /dev/null +++ b/programs/magicblock/src/intent_bundles/outbox/process_set_intent_execution_stage.rs @@ -0,0 +1,128 @@ +use std::collections::HashSet; + +use magicblock_core::intent::outbox::outbox_intent_pda; +use magicblock_magic_program_api::outbox::ExecutionStage; +use solana_account::{ReadableAccount, WritableAccount}; +use solana_instruction::error::InstructionError; +use solana_log_collector::ic_msg; +use solana_program_runtime::invoke_context::InvokeContext; +use solana_pubkey::Pubkey; + +use crate::{ + intent_bundles::outbox_intent_bundles::OutboxIntentBundle, + utils::accounts::{ + get_instruction_account_with_idx, get_instruction_pubkey_with_idx, + }, + validator::effective_validator_authority_id, +}; + +const VALIDATOR_AUTHORITY_IDX: u16 = 0; +const INTENT_PDA_IDX: u16 = 1; + +pub fn process_set_intent_execution_stage( + signers: HashSet, + invoke_context: &mut InvokeContext, + intent_id: u64, + stage: ExecutionStage, +) -> Result<(), InstructionError> { + let validator_auth = effective_validator_authority_id(); + validate(&signers, invoke_context, &validator_auth, intent_id)?; + + set_new_execution_stage(invoke_context, intent_id, stage) +} + +fn validate( + signers: &HashSet, + invoke_context: &InvokeContext, + validator_auth: &Pubkey, + intent_id: u64, +) -> Result<(), InstructionError> { + let transaction_context = &*invoke_context.transaction_context; + + // Check that validator authority signed the tx + let provided_validator_auth = get_instruction_pubkey_with_idx( + transaction_context, + VALIDATOR_AUTHORITY_IDX, + )?; + if provided_validator_auth != validator_auth { + ic_msg!( + invoke_context, + "SetIntentExecutionStage ERR: invalid validator authority {}, should be {}", + provided_validator_auth, + validator_auth + ); + return Err(InstructionError::InvalidArgument); + } + if !signers.contains(validator_auth) { + ic_msg!( + invoke_context, + "SetIntentExecutionStage ERR: validator authority {} not in signers", + validator_auth + ); + return Err(InstructionError::MissingRequiredSignature); + } + + // Validate pda we about to apply transition to + let provided_pda = + get_instruction_pubkey_with_idx(transaction_context, INTENT_PDA_IDX)?; + let expected_pda = outbox_intent_pda(intent_id); + if *provided_pda != expected_pda { + ic_msg!( + invoke_context, + "SetIntentExecutionStage ERR: account at idx {} is {}, expected PDA {} for intent {}", + INTENT_PDA_IDX, + provided_pda, + expected_pda, + intent_id + ); + return Err(InstructionError::InvalidArgument); + } + + Ok(()) +} + +fn set_new_execution_stage( + invoke_context: &InvokeContext, + intent_id: u64, + stage: ExecutionStage, +) -> Result<(), InstructionError> { + let transaction_context = &*invoke_context.transaction_context; + let intent_acc = + get_instruction_account_with_idx(transaction_context, INTENT_PDA_IDX)?; + + let mut bundle = + OutboxIntentBundle::try_from_bytes(intent_acc.borrow()?.data()) + .map_err(|_| { + ic_msg!( + invoke_context, + "SetIntentExecutionStage ERR: failed to deserialize outbox intent {}", + intent_id + ); + InstructionError::InvalidAccountData + })?; + + bundle.apply_stage_transition(stage).map_err(|reason| { + ic_msg!( + invoke_context, + "SetIntentExecutionStage ERR: intent {}: invalid transition: {}", + intent_id, + reason + ); + InstructionError::InvalidArgument + })?; + + let data = bundle.try_to_bytes().map_err(|_| { + ic_msg!( + invoke_context, + "SetIntentExecutionStage ERR: failed to serialize outbox intent {}", + intent_id + ); + InstructionError::InvalidAccountData + })?; + intent_acc + .borrow_mut()? + .data_as_mut_slice() + .copy_from_slice(&data); + + Ok(()) +} diff --git a/programs/magicblock/src/intent_bundles/outbox_intent_bundles.rs b/programs/magicblock/src/intent_bundles/outbox_intent_bundles.rs new file mode 100644 index 000000000..6a2e2b16f --- /dev/null +++ b/programs/magicblock/src/intent_bundles/outbox_intent_bundles.rs @@ -0,0 +1,135 @@ +use std::ops::Deref; + +use magicblock_core::intent::outbox::OUTBOX_INTENT_DISCRIMINATOR; +use magicblock_magic_program_api::outbox::{ + ExecutionStage, PendingTransaction, TwoStageProgress, +}; +use serde::{Deserialize, Serialize}; +use solana_hash::Hash; +use solana_signature::Signature; + +use crate::magic_scheduled_base_intent::ScheduledIntentBundle; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OutboxIntentBundle { + pub inner: ScheduledIntentBundle, + status: OutboxIntentBundleStatus, +} + +impl OutboxIntentBundle { + pub fn accepted(intent_bundle: ScheduledIntentBundle) -> Self { + Self { + inner: intent_bundle, + status: OutboxIntentBundleStatus::Accepted, + } + } + + pub fn status(&self) -> &OutboxIntentBundleStatus { + &self.status + } + + pub(crate) fn apply_stage_transition( + &mut self, + stage: ExecutionStage, + ) -> Result<(), &'static str> { + self.status.apply_stage_transition(stage) + } + + #[cfg(not(feature = "dev-context-only-utils"))] + pub(crate) fn try_to_bytes(&self) -> Result, bincode::Error> { + self.try_to_bytes_impl() + } + + #[cfg(feature = "dev-context-only-utils")] + pub fn try_to_bytes(&self) -> Result, bincode::Error> { + self.try_to_bytes_impl() + } + + fn try_to_bytes_impl(&self) -> Result, bincode::Error> { + const DISCRIMINATOR_LEN: usize = OUTBOX_INTENT_DISCRIMINATOR.len(); + + // bincode serializes structs as field concatenation, so max body size + // is inner size + worst-case status size (TwoStage::Finalizing with 2 sigs) + let max_body_size = (bincode::serialized_size(&self.inner)? + + bincode::serialized_size( + &OutboxIntentBundleStatus::max_size_variant(), + )?) as usize; + + let mut out = vec![0u8; DISCRIMINATOR_LEN + max_body_size]; + out[..DISCRIMINATOR_LEN].copy_from_slice(&OUTBOX_INTENT_DISCRIMINATOR); + bincode::serialize_into( + std::io::Cursor::new(&mut out[DISCRIMINATOR_LEN..]), + self, + )?; + Ok(out) + } + + pub fn try_from_bytes(data: &[u8]) -> Result { + let disc_len = OUTBOX_INTENT_DISCRIMINATOR.len(); + if data.len() < disc_len + || data[..disc_len] != OUTBOX_INTENT_DISCRIMINATOR + { + return Err(Box::new(bincode::ErrorKind::Custom( + "invalid discriminator".into(), + ))); + } + bincode::deserialize(&data[disc_len..]) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum OutboxIntentBundleStatus { + Accepted, + Executing(ExecutionStage), +} + +impl OutboxIntentBundleStatus { + fn max_size_variant() -> Self { + Self::Executing(ExecutionStage::TwoStage( + TwoStageProgress::Finalizing { + commit: Signature::default(), + finalize: PendingTransaction { + signature: Signature::default(), + blockhash: Hash::default(), + }, + }, + )) + } + + fn apply_stage_transition( + &mut self, + stage: ExecutionStage, + ) -> Result<(), &'static str> { + match (self, stage) { + (this @ Self::Accepted, ExecutionStage::TwoStage(stage)) => { + match stage { + // Transition from Accepted to Committing stage + val @ TwoStageProgress::Committing(_) => { + *this = Self::Executing(ExecutionStage::TwoStage(val)); + } + // Transition from Accepted state to TwoStage::Finalizing is invalid + TwoStageProgress::Finalizing { .. } => { + return Err( + "cannot transition from Accepted to Finalizing", + ) + } + } + } + (this @ Self::Accepted, val @ ExecutionStage::SingleStage(_)) => { + *this = Self::Executing(val); + } + (Self::Executing(ref mut this), stage) => { + this.apply_stage_transition(stage)?; + } + }; + Ok(()) + } +} + +impl Deref for OutboxIntentBundle { + type Target = ScheduledIntentBundle; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} diff --git a/programs/magicblock/src/schedule_transactions/process_execute_callback.rs b/programs/magicblock/src/intent_bundles/process_execute_callback.rs similarity index 100% rename from programs/magicblock/src/schedule_transactions/process_execute_callback.rs rename to programs/magicblock/src/intent_bundles/process_execute_callback.rs diff --git a/programs/magicblock/src/schedule_transactions/mod.rs b/programs/magicblock/src/intent_bundles/schedule/mod.rs similarity index 96% rename from programs/magicblock/src/schedule_transactions/mod.rs rename to programs/magicblock/src/intent_bundles/schedule/mod.rs index b16b66a5a..83a2a812b 100644 --- a/programs/magicblock/src/schedule_transactions/mod.rs +++ b/programs/magicblock/src/intent_bundles/schedule/mod.rs @@ -1,12 +1,9 @@ -mod process_accept_scheduled_commits; mod process_add_action_callback; -mod process_execute_callback; mod process_schedule_cloned_undelegation; mod process_schedule_commit; #[cfg(test)] mod process_schedule_commit_tests; mod process_schedule_intent_bundle; -mod process_scheduled_commit_sent; pub(crate) mod transaction_scheduler; use std::sync::Arc; @@ -15,15 +12,10 @@ use magicblock_core::intent::CommittedAccount; use magicblock_magic_program_api::{ pda::CALLBACK_SIGNER, MAGIC_CONTEXT_PUBKEY, }; -pub(crate) use process_accept_scheduled_commits::*; pub(crate) use process_add_action_callback::process_add_action_callback; -pub(crate) use process_execute_callback::*; pub(crate) use process_schedule_cloned_undelegation::process_schedule_cloned_account_undelegation; pub(crate) use process_schedule_commit::*; pub(crate) use process_schedule_intent_bundle::process_schedule_intent_bundle; -pub use process_scheduled_commit_sent::{ - process_scheduled_commit_sent, register_scheduled_commit_sent, SentCommit, -}; use solana_clock::Clock; use solana_instruction::{error::InstructionError, AccountMeta}; use solana_log_collector::ic_msg; @@ -31,6 +23,16 @@ use solana_program_runtime::invoke_context::InvokeContext; use solana_pubkey::Pubkey; use solana_transaction_context::TransactionContext; +pub use crate::intent_bundles::outbox::process_scheduled_commit_sent::{ + process_scheduled_commit_sent, register_scheduled_commit_sent, SentCommit, +}; +pub(crate) use crate::intent_bundles::{ + outbox::{ + process_accept_scheduled_commits::*, + process_set_intent_execution_stage::process_set_intent_execution_stage, + }, + process_execute_callback::*, +}; use crate::{ magic_sys::{ fetch_current_commit_nonces, COMMIT_LIMIT, COMMIT_LIMIT_ERR, diff --git a/programs/magicblock/src/schedule_transactions/process_add_action_callback.rs b/programs/magicblock/src/intent_bundles/schedule/process_add_action_callback.rs similarity index 100% rename from programs/magicblock/src/schedule_transactions/process_add_action_callback.rs rename to programs/magicblock/src/intent_bundles/schedule/process_add_action_callback.rs diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_cloned_undelegation.rs b/programs/magicblock/src/intent_bundles/schedule/process_schedule_cloned_undelegation.rs similarity index 100% rename from programs/magicblock/src/schedule_transactions/process_schedule_cloned_undelegation.rs rename to programs/magicblock/src/intent_bundles/schedule/process_schedule_cloned_undelegation.rs diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_commit.rs b/programs/magicblock/src/intent_bundles/schedule/process_schedule_commit.rs similarity index 100% rename from programs/magicblock/src/schedule_transactions/process_schedule_commit.rs rename to programs/magicblock/src/intent_bundles/schedule/process_schedule_commit.rs diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs b/programs/magicblock/src/intent_bundles/schedule/process_schedule_commit_tests.rs similarity index 89% rename from programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs rename to programs/magicblock/src/intent_bundles/schedule/process_schedule_commit_tests.rs index eeb724135..c5f6f1839 100644 --- a/programs/magicblock/src/schedule_transactions/process_schedule_commit_tests.rs +++ b/programs/magicblock/src/intent_bundles/schedule/process_schedule_commit_tests.rs @@ -20,14 +20,13 @@ use solana_sdk_ids::{system_program, sysvar::clock}; use solana_signer::Signer; use crate::{ + intent_bundles::outbox_intent_bundles::OutboxIntentBundle, magic_context::MagicContext, magic_scheduled_base_intent::{ ScheduledIntentBundle, ACTUAL_COMMIT_LIMIT, COMMIT_FEE_LAMPORTS, }, magic_sys::COMMIT_LIMIT, - schedule_transactions::{ - magic_fee_vault_pubkey, transaction_scheduler::TransactionScheduler, - }, + schedule_transactions::magic_fee_vault_pubkey, test_utils::{ ensure_started_validator, process_instruction, process_instruction_with_logs, StubNonces, @@ -159,44 +158,77 @@ fn find_magic_context_account( .find(|acc| acc.owner() == &crate::id() && acc.lamports() == u64::MAX) } -fn assert_non_accepted_actions<'a>( - processed_scheduled: &'a [AccountSharedData], - payer: &Pubkey, +fn assert_non_accepted_actions( + processed_scheduled: &[AccountSharedData], expected_non_accepted_commits: usize, -) -> &'a AccountSharedData { +) -> &AccountSharedData { let magic_context_acc = find_magic_context_account(processed_scheduled) .expect("magic context account not found"); let magic_context = bincode::deserialize::(magic_context_acc.data()).unwrap(); - let accepted_scheduled_actions = - TransactionScheduler::default().get_scheduled_actions_by_payer(payer); assert_eq!( magic_context.scheduled_base_intents.len(), expected_non_accepted_commits ); - assert_eq!(accepted_scheduled_actions.len(), 0); magic_context_acc } fn assert_accepted_actions( processed_accepted: &[AccountSharedData], - payer: &Pubkey, - expected_scheduled_actions: usize, + pre_accept_magic_context: &AccountSharedData, + expected_accepted_count: usize, ) -> Vec { - let magic_context_acc = find_magic_context_account(processed_accepted) + let post_magic_context_acc = find_magic_context_account(processed_accepted) .expect("magic context account not found"); - let magic_context = - bincode::deserialize::(magic_context_acc.data()).unwrap(); - - let scheduled_actions = - TransactionScheduler::default().get_scheduled_actions_by_payer(payer); + let post_magic_context = + bincode::deserialize::(post_magic_context_acc.data()) + .unwrap(); + assert_eq!(post_magic_context.scheduled_base_intents.len(), 0); + + let pre_magic_context = + bincode::deserialize::(pre_accept_magic_context.data()) + .unwrap(); + let accepted_intents = pre_magic_context.scheduled_base_intents; + assert_eq!(accepted_intents.len(), expected_accepted_count); + + for intent in &accepted_intents { + let expected = OutboxIntentBundle::accepted(intent.clone()); + let actual = processed_accepted + .iter() + .filter(|acc| acc.owner() == &crate::id() && acc.ephemeral()) + .filter_map(|acc| { + OutboxIntentBundle::try_from_bytes(acc.data()).ok() + }) + .find(|bundle| bundle.inner.id == intent.id) + .unwrap_or_else(|| { + panic!( + "outbox PDA for intent {} not found in processed_accepted", + intent.id + ) + }); + assert_eq!(actual, expected); + } - assert_eq!(magic_context.scheduled_base_intents.len(), 0); - assert_eq!(scheduled_actions.len(), expected_scheduled_actions); + accepted_intents +} - scheduled_actions +/// Pre-populates uninitialized outbox intent PDA accounts into `account_data`. +/// The accept instruction creates these accounts via CPI (`CreateEphemeralAccount`), +/// which requires them to be present in the transaction context as uninitialized +/// system-owned entries — exactly what `validate_new_ephemeral` expects. +/// The first 4 accounts (validator, program, magic_context, vault) are already +/// in `account_data`, so we skip them. +fn ensure_outbox_pda_accounts_exist( + account_data: &mut HashMap, + accept_ix: &Instruction, +) { + for acc_meta in accept_ix.accounts.iter().skip(4) { + account_data.entry(acc_meta.pubkey).or_insert_with(|| { + AccountSharedData::new(0, 0, &system_program::id()) + }); + } } fn extend_transaction_accounts_from_ix( @@ -260,7 +292,7 @@ fn assert_first_commit( assert!(intent_bundle.commit_finalize.is_some()); assert!(intent_bundle.commit_finalize_and_undelegate.is_none()); } - let _instruction = MagicBlockInstruction::ScheduledCommitSent((*id, 0)); + let _instruction = MagicBlockInstruction::ScheduledCommitSent(*id); // TODO(edwin) @@@ this fails in CI only with the similar to the below // left: [4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0] // right: [4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] @@ -347,11 +379,8 @@ mod tests { // At this point the intent to commit was added to the magic context account, // but not yet accepted - let magic_context_acc = assert_non_accepted_actions( - &processed_scheduled, - &payer.pubkey(), - 1, - ); + let magic_context_acc = + assert_non_accepted_actions(&processed_scheduled, 1); (processed_scheduled.clone(), magic_context_acc.clone()) }; @@ -363,7 +392,17 @@ mod tests { &payer, program, committee, ); - let ix = InstructionUtils::accept_scheduled_commits_instruction(); + let intent_ids = + bincode::deserialize::(magic_context_acc.data()) + .unwrap() + .scheduled_base_intents + .into_iter() + .map(|i| i.id) + .collect::>(); + let ix = InstructionUtils::accept_scheduled_commits_instruction( + intent_ids.into_iter(), + ); + ensure_outbox_pda_accounts_exist(&mut account_data, &ix); extend_transaction_accounts_from_ix_adding_magic_context( &ix, &magic_context_acc, @@ -381,7 +420,7 @@ mod tests { // At this point the intended commits were accepted and moved to the global let scheduled_intents = assert_accepted_actions( &processed_accepted, - &payer.pubkey(), + &magic_context_acc, 1, ); @@ -474,11 +513,8 @@ mod tests { Ok(()), ); - let magic_context_acc = assert_non_accepted_actions( - &processed_scheduled, - &payer.pubkey(), - 1, - ); + let magic_context_acc = + assert_non_accepted_actions(&processed_scheduled, 1); let magic_context = bincode::deserialize::(magic_context_acc.data()) .unwrap(); @@ -535,11 +571,8 @@ mod tests { // At this point the intent to commit was added to the magic context account, // but not yet accepted - let magic_context_acc = assert_non_accepted_actions( - &processed_scheduled, - &payer.pubkey(), - 1, - ); + let magic_context_acc = + assert_non_accepted_actions(&processed_scheduled, 1); (processed_scheduled.clone(), magic_context_acc.clone()) }; @@ -551,7 +584,17 @@ mod tests { &payer, program, committee, ); - let ix = InstructionUtils::accept_scheduled_commits_instruction(); + let intent_ids = + bincode::deserialize::(magic_context_acc.data()) + .unwrap() + .scheduled_base_intents + .into_iter() + .map(|i| i.id) + .collect::>(); + let ix = InstructionUtils::accept_scheduled_commits_instruction( + intent_ids.into_iter(), + ); + ensure_outbox_pda_accounts_exist(&mut account_data, &ix); extend_transaction_accounts_from_ix_adding_magic_context( &ix, &magic_context_acc, @@ -569,7 +612,7 @@ mod tests { // At this point the intended commits were accepted and moved to the global let scheduled_commits = assert_accepted_actions( &processed_accepted, - &payer.pubkey(), + &magic_context_acc, 1, ); @@ -630,20 +673,26 @@ mod tests { ); // Extract magic context and then accept scheduled commits - let magic_context_acc = assert_non_accepted_actions( - &processed_scheduled, - &payer.pubkey(), - 1, - ); + let magic_context_acc = + assert_non_accepted_actions(&processed_scheduled, 1); - let ix_accept = - InstructionUtils::accept_scheduled_commits_instruction(); + let intent_ids = + bincode::deserialize::(magic_context_acc.data()) + .unwrap() + .scheduled_base_intents + .into_iter() + .map(|i| i.id) + .collect::>(); + let ix_accept = InstructionUtils::accept_scheduled_commits_instruction( + intent_ids.into_iter(), + ); let (mut account_data2, mut transaction_accounts2) = prepare_transaction_with_single_committee( &payer, Pubkey::new_unique(), ata_pubkey, ); + ensure_outbox_pda_accounts_exist(&mut account_data2, &ix_accept); extend_transaction_accounts_from_ix_adding_magic_context( &ix_accept, magic_context_acc, @@ -658,7 +707,7 @@ mod tests { ); let scheduled = - assert_accepted_actions(&processed_accepted, &payer.pubkey(), 1); + assert_accepted_actions(&processed_accepted, magic_context_acc, 1); // Verify the committed pubkey remapped to eATA assert_eq!( scheduled[0].intent_bundle.get_all_committed_pubkeys(), @@ -713,11 +762,8 @@ mod tests { ix.accounts, Ok(()), ); - let magic_context_acc = assert_non_accepted_actions( - &processed_scheduled, - &payer.pubkey(), - 1, - ); + let magic_context_acc = + assert_non_accepted_actions(&processed_scheduled, 1); let magic_context = bincode::deserialize::(magic_context_acc.data()) .unwrap(); @@ -776,20 +822,26 @@ mod tests { ); // Extract magic context and then accept scheduled commits - let magic_context_acc = assert_non_accepted_actions( - &processed_scheduled, - &payer.pubkey(), - 1, - ); + let magic_context_acc = + assert_non_accepted_actions(&processed_scheduled, 1); - let ix_accept = - InstructionUtils::accept_scheduled_commits_instruction(); + let intent_ids = + bincode::deserialize::(magic_context_acc.data()) + .unwrap() + .scheduled_base_intents + .into_iter() + .map(|i| i.id) + .collect::>(); + let ix_accept = InstructionUtils::accept_scheduled_commits_instruction( + intent_ids.into_iter(), + ); let (mut account_data2, mut transaction_accounts2) = prepare_transaction_with_single_committee( &payer, Pubkey::new_unique(), ata_pubkey, ); + ensure_outbox_pda_accounts_exist(&mut account_data2, &ix_accept); extend_transaction_accounts_from_ix_adding_magic_context( &ix_accept, magic_context_acc, @@ -804,7 +856,7 @@ mod tests { ); let scheduled = - assert_accepted_actions(&processed_accepted, &payer.pubkey(), 1); + assert_accepted_actions(&processed_accepted, magic_context_acc, 1); // Verify the committed pubkey remapped to eATA assert_eq!( scheduled[0].intent_bundle.get_all_committed_pubkeys(), @@ -865,11 +917,8 @@ mod tests { // At this point the intent to commit was added to the magic context account, // but not yet accepted - let magic_context_acc = assert_non_accepted_actions( - &processed_scheduled, - &payer.pubkey(), - 1, - ); + let magic_context_acc = + assert_non_accepted_actions(&processed_scheduled, 1); ( processed_scheduled.clone(), @@ -893,7 +942,17 @@ mod tests { (true, true, true), ); - let ix = InstructionUtils::accept_scheduled_commits_instruction(); + let intent_ids = + bincode::deserialize::(magic_context_acc.data()) + .unwrap() + .scheduled_base_intents + .into_iter() + .map(|i| i.id) + .collect::>(); + let ix = InstructionUtils::accept_scheduled_commits_instruction( + intent_ids.into_iter(), + ); + ensure_outbox_pda_accounts_exist(&mut accounts_data, &ix); extend_transaction_accounts_from_ix_adding_magic_context( &ix, &magic_context_acc, @@ -911,7 +970,7 @@ mod tests { // At this point the intended commits were accepted and moved to the global let scheduled_commits = assert_accepted_actions( &processed_accepted, - &payer.pubkey(), + &magic_context_acc, 1, ); @@ -980,11 +1039,8 @@ mod tests { // At this point the intent to commit was added to the magic context account, // but not yet accepted - let magic_context_acc = assert_non_accepted_actions( - &processed_scheduled, - &payer.pubkey(), - 1, - ); + let magic_context_acc = + assert_non_accepted_actions(&processed_scheduled, 1); ( processed_scheduled.clone(), @@ -1008,7 +1064,17 @@ mod tests { (true, true, true), ); - let ix = InstructionUtils::accept_scheduled_commits_instruction(); + let intent_ids = + bincode::deserialize::(magic_context_acc.data()) + .unwrap() + .scheduled_base_intents + .into_iter() + .map(|i| i.id) + .collect::>(); + let ix = InstructionUtils::accept_scheduled_commits_instruction( + intent_ids.into_iter(), + ); + ensure_outbox_pda_accounts_exist(&mut accounts_data, &ix); extend_transaction_accounts_from_ix_adding_magic_context( &ix, &magic_context_acc, @@ -1026,7 +1092,7 @@ mod tests { // At this point the intended commits were accepted and moved to the global let scheduled_commits = assert_accepted_actions( &processed_accepted, - &payer.pubkey(), + &magic_context_acc, 1, ); diff --git a/programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs b/programs/magicblock/src/intent_bundles/schedule/process_schedule_intent_bundle.rs similarity index 100% rename from programs/magicblock/src/schedule_transactions/process_schedule_intent_bundle.rs rename to programs/magicblock/src/intent_bundles/schedule/process_schedule_intent_bundle.rs diff --git a/programs/magicblock/src/schedule_transactions/transaction_scheduler.rs b/programs/magicblock/src/intent_bundles/schedule/transaction_scheduler.rs similarity index 100% rename from programs/magicblock/src/schedule_transactions/transaction_scheduler.rs rename to programs/magicblock/src/intent_bundles/schedule/transaction_scheduler.rs diff --git a/programs/magicblock/src/lib.rs b/programs/magicblock/src/lib.rs index 69f768583..eee7540ff 100644 --- a/programs/magicblock/src/lib.rs +++ b/programs/magicblock/src/lib.rs @@ -5,15 +5,17 @@ mod magic_context; pub mod magic_sys; mod mutate_accounts; mod schedule_task; -mod schedule_transactions; pub use magic_context::MagicContext; -pub mod magic_scheduled_base_intent; +mod intent_bundles; pub mod magicblock_processor; pub mod test_utils; mod utils; pub mod validator; - pub use clone_account::is_pending_clone; +pub use intent_bundles::{ + magic_scheduled_base_intent, outbox_intent_bundles, + schedule as schedule_transactions, +}; pub use magic_sys::init_magic_sys; pub use magicblock_magic_program_api::*; pub use schedule_transactions::{ diff --git a/programs/magicblock/src/magic_context.rs b/programs/magicblock/src/magic_context.rs index 51d819db0..a7505ee7b 100644 --- a/programs/magicblock/src/magic_context.rs +++ b/programs/magicblock/src/magic_context.rs @@ -16,7 +16,7 @@ impl MagicContext { pub const SIZE: usize = MAGIC_CONTEXT_SIZE; pub const ZERO: [u8; Self::SIZE] = [0; Self::SIZE]; - pub(crate) fn deserialize(data: &[u8]) -> Result { + pub fn deserialize(data: &[u8]) -> Result { if data.is_empty() || is_zeroed(data) { Ok(Self::default()) } else { @@ -53,26 +53,41 @@ impl MagicContext { self.scheduled_base_intents.push(base_intent); } - pub(crate) fn take_scheduled_commits( + pub(crate) fn take_front_scheduled_commits( &mut self, + n: usize, ) -> Vec { - mem::take(&mut self.scheduled_base_intents) + let n = n.min(self.scheduled_base_intents.len()); + self.scheduled_base_intents.drain(..n).collect() } - pub fn has_scheduled_commits(data: &[u8]) -> bool { - const LEN_OFF: usize = mem::size_of::(); - const LEN_END: usize = LEN_OFF + mem::size_of::(); + /// Returns `intent_id` store in `MagicContext` without deserializing whole account + pub fn intent_id(data: &[u8]) -> Option { + const ID_OFFSET: usize = 0; + const ID_END: usize = mem::size_of::(); + + let raw_id = data.get(ID_OFFSET..ID_END)?; + + let mut buf = [0; mem::size_of::()]; + buf.copy_from_slice(raw_id); + Some(u64::from_le_bytes(buf)) + } + + pub fn scheduled_intents_len(data: &[u8]) -> Option { + const LEN_OFFSET: usize = mem::size_of::(); + const LEN_END: usize = LEN_OFFSET + mem::size_of::(); if is_zeroed(data) { - return false; + return None; } - - let Some(raw_len) = data.get(LEN_OFF..LEN_END) else { - return false; - }; + let raw_len = data.get(LEN_OFFSET..LEN_END)?; let mut len = [0; mem::size_of::()]; len.copy_from_slice(raw_len); - u64::from_le_bytes(len) != 0 + Some(u64::from_le_bytes(len)) + } + + pub fn has_scheduled_intents(data: &[u8]) -> bool { + Self::scheduled_intents_len(data).unwrap_or(0) != 0 } } diff --git a/programs/magicblock/src/magicblock_processor.rs b/programs/magicblock/src/magicblock_processor.rs index d39ed2486..9b792db9a 100644 --- a/programs/magicblock/src/magicblock_processor.rs +++ b/programs/magicblock/src/magicblock_processor.rs @@ -28,7 +28,7 @@ use crate::{ process_accept_scheduled_commits, process_add_action_callback, process_execute_callback, process_schedule_cloned_account_undelegation, process_schedule_commit, process_schedule_intent_bundle, - ProcessScheduleCommitOptions, + process_set_intent_execution_stage, ProcessScheduleCommitOptions, }, }; @@ -96,12 +96,17 @@ declare_process_instruction!( AcceptScheduleCommits => { process_accept_scheduled_commits(signers, invoke_context) } - ScheduledCommitSent((id, _bump)) => process_scheduled_commit_sent( - signers, - invoke_context, - transaction_context, - id, - ), + SetIntentExecutionStage { intent_id, stage } => { + process_set_intent_execution_stage( + signers, + invoke_context, + intent_id, + stage, + ) + } + ScheduledCommitSent(id) => { + process_scheduled_commit_sent(signers, invoke_context, id) + } ScheduleBaseIntent(args) => process_schedule_intent_bundle( signers, invoke_context, diff --git a/programs/magicblock/src/schedule_transactions/process_accept_scheduled_commits.rs b/programs/magicblock/src/schedule_transactions/process_accept_scheduled_commits.rs deleted file mode 100644 index cc4cc844f..000000000 --- a/programs/magicblock/src/schedule_transactions/process_accept_scheduled_commits.rs +++ /dev/null @@ -1,97 +0,0 @@ -use std::collections::HashSet; - -use solana_account::{ReadableAccount, WritableAccount}; -use solana_instruction::error::InstructionError; -use solana_log_collector::ic_msg; -use solana_program_runtime::invoke_context::InvokeContext; -use solana_pubkey::Pubkey; - -use crate::{ - schedule_transactions, - utils::accounts::{ - get_instruction_account_with_idx, get_instruction_pubkey_with_idx, - }, - validator::effective_validator_authority_id, - MagicContext, TransactionScheduler, -}; - -pub fn process_accept_scheduled_commits( - signers: HashSet, - invoke_context: &mut InvokeContext, -) -> Result<(), InstructionError> { - const VALIDATOR_AUTHORITY_IDX: u16 = 0; - const MAGIC_CONTEXT_IDX: u16 = VALIDATOR_AUTHORITY_IDX + 1; - - let transaction_context = &*invoke_context.transaction_context; - - // 1. Read all scheduled commits from the `MagicContext` account - // We do this first so we can skip all checks in case there is nothing - // to be processed - schedule_transactions::check_magic_context_id( - invoke_context, - MAGIC_CONTEXT_IDX, - )?; - let magic_context_acc = get_instruction_account_with_idx( - transaction_context, - MAGIC_CONTEXT_IDX, - )?; - let mut magic_context = MagicContext::deserialize( - magic_context_acc.borrow()?.data(), - ) - .map_err(|err| { - ic_msg!( - invoke_context, - "Failed to deserialize MagicContext: {}", - err - ); - InstructionError::InvalidAccountData - })?; - if magic_context.scheduled_base_intents.is_empty() { - ic_msg!( - invoke_context, - "AcceptScheduledCommits: no scheduled commits to accept" - ); - // NOTE: we should have not been called if no commits are scheduled - return Ok(()); - } - - // 2. Check that the validator authority (first account) is correct and signer - let provided_validator_auth = get_instruction_pubkey_with_idx( - transaction_context, - VALIDATOR_AUTHORITY_IDX, - )?; - let validator_auth = effective_validator_authority_id(); - if !provided_validator_auth.eq(&validator_auth) { - ic_msg!( - invoke_context, - "AcceptScheduledCommits ERR: invalid validator authority {}, should be {}", - provided_validator_auth, - validator_auth - ); - return Err(InstructionError::InvalidArgument); - } - if !signers.contains(&validator_auth) { - ic_msg!( - invoke_context, - "AcceptScheduledCommits ERR: validator authority pubkey {} not in signers", - validator_auth - ); - return Err(InstructionError::MissingRequiredSignature); - } - - // 3. Move scheduled commits (without copying) - let scheduled_commits = magic_context.take_scheduled_commits(); - ic_msg!( - invoke_context, - "AcceptScheduledCommits: accepted {} scheduled commit(s)", - scheduled_commits.len() - ); - TransactionScheduler::default() - .accept_scheduled_base_intent(scheduled_commits); - - // 4. Serialize and store the updated `MagicContext` account - magic_context - .write_to(magic_context_acc.borrow_mut()?.data_as_mut_slice())?; - - Ok(()) -} diff --git a/programs/magicblock/src/utils/instruction_utils.rs b/programs/magicblock/src/utils/instruction_utils.rs index 8a9702b86..84bc19c94 100644 --- a/programs/magicblock/src/utils/instruction_utils.rs +++ b/programs/magicblock/src/utils/instruction_utils.rs @@ -1,16 +1,15 @@ -use std::{ - collections::HashMap, - sync::atomic::{AtomicU64, Ordering}, -}; +use std::collections::HashMap; +use magicblock_core::intent::outbox::outbox_intent_pda; use magicblock_magic_program_api::{ args::ScheduleTaskArgs, instruction::{ AccountModification, AccountModificationForInstruction, MagicBlockInstruction, PostDelegationActionExecutorInstruction, }, + outbox, pda::crank_signer_pda, - CRANK_PROGRAM_ID, MAGIC_CONTEXT_PUBKEY, + CRANK_PROGRAM_ID, EPHEMERAL_VAULT_PUBKEY, MAGIC_CONTEXT_PUBKEY, POST_DELEGATION_ACTION_EXECUTOR_PROGRAM_ID, }; use solana_hash::Hash; @@ -37,8 +36,7 @@ impl InstructionUtils { Self::into_transaction(payer, ix, recent_blockhash) } - #[cfg(test)] - pub(crate) fn schedule_commit_instruction( + pub fn schedule_commit_instruction( payer: &Pubkey, pdas: Vec, ) -> Instruction { @@ -117,30 +115,22 @@ impl InstructionUtils { scheduled_commit_id: u64, recent_blockhash: Hash, ) -> Transaction { - let ix = Self::scheduled_commit_sent_instruction( - &crate::id(), - &validator_authority_id(), - scheduled_commit_id, - ); + let ix = Self::scheduled_commit_sent_instruction(scheduled_commit_id); Self::into_transaction(&validator_authority(), ix, recent_blockhash) } pub(crate) fn scheduled_commit_sent_instruction( - magic_block_program: &Pubkey, - validator_authority: &Pubkey, scheduled_commit_id: u64, ) -> Instruction { - static COMMIT_SENT_BUMP: AtomicU64 = AtomicU64::new(0); let account_metas = vec![ - AccountMeta::new_readonly(*magic_block_program, false), - AccountMeta::new_readonly(*validator_authority, true), + AccountMeta::new(validator_authority_id(), true), + AccountMeta::new_readonly(crate::id(), false), + AccountMeta::new(EPHEMERAL_VAULT_PUBKEY, false), + AccountMeta::new(outbox_intent_pda(scheduled_commit_id), false), ]; Instruction::new_with_bincode( - *magic_block_program, - &MagicBlockInstruction::ScheduledCommitSent(( - scheduled_commit_id, - COMMIT_SENT_BUMP.fetch_add(1, Ordering::SeqCst), - )), + crate::id(), + &MagicBlockInstruction::ScheduledCommitSent(scheduled_commit_id), account_metas, ) } @@ -148,16 +138,31 @@ impl InstructionUtils { // ----------------- // Accept Scheduled Commits // ----------------- - pub fn accept_scheduled_commits(recent_blockhash: Hash) -> Transaction { - let ix = Self::accept_scheduled_commits_instruction(); + pub fn accept_scheduled_commits( + recent_blockhash: Hash, + intent_ids: impl IntoIterator, + ) -> Transaction { + let ix = Self::accept_scheduled_commits_instruction(intent_ids); Self::into_transaction(&validator_authority(), ix, recent_blockhash) } - pub(crate) fn accept_scheduled_commits_instruction() -> Instruction { - let account_metas = vec![ - AccountMeta::new_readonly(validator_authority_id(), true), + pub fn accept_scheduled_commits_instruction( + intent_ids: impl IntoIterator, + ) -> Instruction { + let mut account_metas = vec![ + AccountMeta::new(validator_authority_id(), true), + AccountMeta::new_readonly(crate::id(), false), AccountMeta::new(MAGIC_CONTEXT_PUBKEY, false), + AccountMeta::new(EPHEMERAL_VAULT_PUBKEY, false), ]; + + // Add outbox intent accounts + let outbox_intent_metas = intent_ids + .into_iter() + .map(outbox_intent_pda) + .map(|intent_pda| AccountMeta::new(intent_pda, false)); + account_metas.extend(outbox_intent_metas); + Instruction::new_with_bincode( crate::id(), &MagicBlockInstruction::AcceptScheduleCommits, @@ -165,6 +170,37 @@ impl InstructionUtils { ) } + // ----------------- + // SetIntentExecutionStage + // ----------------- + + pub fn set_intent_execution_stage( + recent_blockhash: Hash, + intent_id: u64, + stage: outbox::ExecutionStage, + ) -> Transaction { + let ix = Self::set_intent_execution_stage_instruction(intent_id, stage); + Self::into_transaction(&validator_authority(), ix, recent_blockhash) + } + + pub(crate) fn set_intent_execution_stage_instruction( + intent_id: u64, + stage: outbox::ExecutionStage, + ) -> Instruction { + let account_metas = vec![ + AccountMeta::new_readonly(validator_authority_id(), true), + AccountMeta::new(outbox_intent_pda(intent_id), false), + ]; + Instruction::new_with_bincode( + crate::id(), + &MagicBlockInstruction::SetIntentExecutionStage { + intent_id, + stage, + }, + account_metas, + ) + } + // ----------------- // ModifyAccounts // ----------------- diff --git a/test-integration/Cargo.lock b/test-integration/Cargo.lock index 0238a889c..8339bc05a 100644 --- a/test-integration/Cargo.lock +++ b/test-integration/Cargo.lock @@ -732,6 +732,20 @@ dependencies = [ "tower-service", ] +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "futures-core", + "getrandom 0.2.17", + "instant", + "pin-project-lite", + "rand 0.8.6", + "tokio", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -2102,7 +2116,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3042,6 +3056,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "integration-test-tools" version = "0.0.0" @@ -3756,6 +3779,7 @@ name = "magicblock-committor-service" version = "0.13.4" dependencies = [ "async-trait", + "backoff", "bincode", "borsh 1.7.0", "futures-util", @@ -3770,6 +3794,7 @@ dependencies = [ "magicblock-program", "magicblock-rpc-client", "magicblock-table-mania", + "pin-project", "rusqlite", "solana-account 3.4.0", "solana-account-decoder", @@ -3791,6 +3816,7 @@ dependencies = [ "solana-transaction-status-client-types", "thiserror 2.0.18", "tokio", + "tokio-stream", "tokio-util", "tracing", ] @@ -3963,6 +3989,7 @@ dependencies = [ "bincode", "const-crypto", "serde", + "solana-hash 4.2.0", "solana-program 3.0.0", "solana-signature", ] @@ -4451,7 +4478,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -9969,15 +9996,27 @@ dependencies = [ name = "test-schedule-intent" version = "0.0.0" dependencies = [ + "anyhow", + "async-trait", "ephemeral-rollups-sdk", "integration-test-tools", "log", + "magicblock-committor-service", + "magicblock-core", "magicblock-delegation-program-api 0.3.0", "magicblock-magic-program-api 0.13.4", + "magicblock-program", + "magicblock-rpc-client", + "magicblock-table-mania", "program-flexi-counter", + "serde_json", + "serial_test", + "solana-commitment-config", + "solana-rpc-client", "solana-rpc-client-api", "solana-sdk", "test-kit", + "tokio", "tracing", ] @@ -11099,6 +11138,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/test-integration/Cargo.toml b/test-integration/Cargo.toml index 05da5a293..e364f1478 100644 --- a/test-integration/Cargo.toml +++ b/test-integration/Cargo.toml @@ -78,6 +78,7 @@ rayon = "1.10.0" rkyv = "0.7.45" schedulecommit-client = { path = "schedulecommit/client" } serde = "1.0.217" +serde_json = "1.0" serial_test = "3.2.0" shlex = "1.3.0" solana-account = { git = "https://github.com/magicblock-labs/magicblock-svm.git", rev = "190146af2ac0890848b50e8fc9d6c926c8205b5e" } diff --git a/test-integration/programs/flexi-counter/src/processor/callback.rs b/test-integration/programs/flexi-counter/src/processor/callback.rs index 6f29e595e..447fba307 100644 --- a/test-integration/programs/flexi-counter/src/processor/callback.rs +++ b/test-integration/programs/flexi-counter/src/processor/callback.rs @@ -80,9 +80,8 @@ pub fn process_transfer_action_handler( /// counter PDA retains the pre-payment as the cost of the deal. /// /// On failure (`ok == false`): moves `amount` lamports from the counter PDA -/// back to the payer on Base (refund). The counter PDA can be written because -/// at callback time the account has already been undelegated and is owned by -/// the flexi-counter program again. +/// back to the payer on ER (refund). The counter PDA can be written because +/// at callback time the account is still delegated /// /// Accounts: /// 0. [signer, readonly] validator authority (auto-prepended by magic program) diff --git a/test-integration/test-committor-service/Cargo.toml b/test-integration/test-committor-service/Cargo.toml index 6035b5ec8..0fe490d69 100644 --- a/test-integration/test-committor-service/Cargo.toml +++ b/test-integration/test-committor-service/Cargo.toml @@ -12,7 +12,7 @@ magicblock-core = { workspace = true } magicblock-committor-program = { workspace = true, features = [ "no-entrypoint", ] } -magicblock-committor-service = { workspace = true } +magicblock-committor-service = { workspace = true, features = ["dev-context-only-utils"] } magicblock-delegation-program-api = { workspace = true } magicblock-program = { workspace = true } magicblock-rpc-client = { workspace = true } diff --git a/test-integration/test-committor-service/tests/common.rs b/test-integration/test-committor-service/tests/common.rs index 66f896211..790279a55 100644 --- a/test-integration/test-committor-service/tests/common.rs +++ b/test-integration/test-committor-service/tests/common.rs @@ -9,13 +9,23 @@ use std::{ use async_trait::async_trait; use magicblock_committor_service::{ intent_executor::{ + accepted_intent_executor::AcceptedIntentExecutor, + error::IntentExecutorResult, + intent_execution_client::IntentExecutionClient, ExecutionOutput, + IntentExecutionReport, IntentExecutorCtx, + }, + outbox::{ + outbox_client::InternalOutboxClientError, + outbox_intent_bundles_reader::OutboxIntentBundlesReader, OutboxClient, + ScheduledBaseIntentMeta, + }, + tasks::{ + commit_task::{CommitDelivery, CommitTask}, task_info_fetcher::{ CacheTaskInfoFetcher, TaskInfoFetcher, TaskInfoFetcherError, TaskInfoFetcherResult, }, - IntentExecutorImpl, }, - tasks::commit_task::{CommitDelivery, CommitTask}, transaction_preparator::{ delivery_preparator::DeliveryPreparator, TransactionPreparatorImpl, }, @@ -25,6 +35,10 @@ use magicblock_core::{ intent::{BaseActionCallback, CommittedAccount}, traits::{ActionResult, ActionsCallbackScheduler, CallbackScheduleError}, }; +use magicblock_program::{ + magic_scheduled_base_intent::ScheduledIntentBundle, outbox::ExecutionStage, + outbox_intent_bundles::OutboxIntentBundle, +}; use magicblock_rpc_client::MagicblockRpcClient; use magicblock_table_mania::{GarbageCollectorConfig, TableMania}; use solana_account::Account; @@ -108,18 +122,23 @@ impl TestFixture { #[allow(dead_code)] pub fn create_intent_executor( &self, - ) -> IntentExecutorImpl< + ) -> AcceptedIntentExecutor< TransactionPreparatorImpl, MockTaskInfoFetcher, MockActionsCallbackExecutor, + MockOutboxClient, > { - let transaction_preparator = self.create_transaction_preparator(); - - IntentExecutorImpl::new( - self.rpc_client.clone(), - transaction_preparator, - self.create_task_info_fetcher(), - MockActionsCallbackExecutor::default(), + AcceptedIntentExecutor::new( + IntentExecutorCtx { + intent_client: IntentExecutionClient::new( + self.rpc_client.clone(), + ), + transaction_preparator: self.create_transaction_preparator(), + task_info_fetcher: self.create_task_info_fetcher(), + outbox_client: Arc::new(MockOutboxClient), + actions_callback_executor: MockActionsCallbackExecutor::default( + ), + }, DEFAULT_ACTIONS_TIMEOUT, ) } @@ -134,6 +153,66 @@ impl TestFixture { } } +pub struct MockOutboxReader; + +#[async_trait] +impl OutboxIntentBundlesReader for MockOutboxReader { + type Error = std::convert::Infallible; + + async fn read( + &mut self, + _n: usize, + ) -> Result, Self::Error> { + Ok(vec![]) + } + + async fn fetch_outbox_intent( + &self, + _intent_id: u64, + ) -> Result, Self::Error> { + Ok(None) + } +} + +#[derive(Clone)] +pub struct MockOutboxClient; + +#[async_trait] +impl OutboxClient for MockOutboxClient { + type Error = InternalOutboxClientError; + type OutboxReader = MockOutboxReader; + + async fn accept_scheduled_intents( + &self, + ) -> Result< + Vec, + (Vec, Self::Error), + > { + Ok(vec![]) + } + + async fn set_intent_execution_stage( + &self, + _intent_id: u64, + _stage: ExecutionStage, + ) -> Result<(), Self::Error> { + Ok(()) + } + + async fn notify_commit_sent( + &self, + _meta: ScheduledBaseIntentMeta, + _result: &IntentExecutorResult, + _execution_report: &IntentExecutionReport, + ) -> Result<(), Self::Error> { + Ok(()) + } + + fn outbox_reader(&self) -> Self::OutboxReader { + MockOutboxReader + } +} + type CallbackCalls = Vec<(Vec, ActionResult)>; #[derive(Clone, Default)] diff --git a/test-integration/test-committor-service/tests/test_delivery_preparator.rs b/test-integration/test-committor-service/tests/test_delivery_preparator.rs index 403cd6f7e..db4441d61 100644 --- a/test-integration/test-committor-service/tests/test_delivery_preparator.rs +++ b/test-integration/test-committor-service/tests/test_delivery_preparator.rs @@ -1,12 +1,9 @@ use borsh::BorshDeserialize; use magicblock_committor_program::Chunks; -use magicblock_committor_service::{ - persist::IntentPersisterImpl, - tasks::{ - commit_task::{CommitBufferStage, CommitDelivery}, - task_strategist::{TaskStrategist, TransactionStrategy}, - BaseTaskImpl, - }, +use magicblock_committor_service::tasks::{ + commit_task::{CommitBufferStage, CommitDelivery}, + task_strategist::{TaskStrategist, TransactionStrategy}, + BaseTaskImpl, }; use solana_sdk::signer::Signer; @@ -31,11 +28,7 @@ async fn test_prepare_10kb_buffer() { // Test preparation let result = preparator - .prepare_for_delivery( - &fixture.authority, - &mut strategy, - &None::, - ) + .prepare_for_delivery(&fixture.authority, &mut strategy) .await; assert!(result.is_ok(), "Preparation failed: {:?}", result.err()); @@ -101,11 +94,7 @@ async fn test_prepare_multiple_buffers() { // Test preparation let result = preparator - .prepare_for_delivery( - &fixture.authority, - &mut strategy, - &None::, - ) + .prepare_for_delivery(&fixture.authority, &mut strategy) .await; assert!(result.is_ok(), "Preparation failed: {:?}", result.err()); @@ -184,11 +173,7 @@ async fn test_lookup_tables() { }; let result = preparator - .prepare_for_delivery( - &fixture.authority, - &mut strategy, - &None::, - ) + .prepare_for_delivery(&fixture.authority, &mut strategy) .await; assert!(result.is_ok(), "Failed to prepare lookup tables"); @@ -221,11 +206,7 @@ async fn test_already_initialized_error_handled() { // Test preparation let result = preparator - .prepare_for_delivery( - &fixture.authority, - &mut strategy, - &None::, - ) + .prepare_for_delivery(&fixture.authority, &mut strategy) .await; assert!(result.is_ok(), "Preparation failed: {:?}", result.err()); @@ -263,11 +244,7 @@ async fn test_already_initialized_error_handled() { // Test preparation let result = preparator - .prepare_for_delivery( - &fixture.authority, - &mut strategy, - &None::, - ) + .prepare_for_delivery(&fixture.authority, &mut strategy) .await; assert!(result.is_ok(), "Preparation failed: {:?}", result.err()); @@ -321,11 +298,7 @@ async fn test_prepare_cleanup_and_reprepare_mixed_tasks() { // --- Step 1: initial prepare --- let res = preparator - .prepare_for_delivery( - &fixture.authority, - &mut strategy, - &None::, - ) + .prepare_for_delivery(&fixture.authority, &mut strategy) .await; assert!(res.is_ok(), "Initial prepare failed: {:?}", res.err()); @@ -424,11 +397,7 @@ async fn test_prepare_cleanup_and_reprepare_mixed_tasks() { }; let res2 = preparator - .prepare_for_delivery( - &fixture.authority, - &mut strategy2, - &None::, - ) + .prepare_for_delivery(&fixture.authority, &mut strategy2) .await; assert!( res2.is_ok(), diff --git a/test-integration/test-committor-service/tests/test_intent_executor.rs b/test-integration/test-committor-service/tests/test_intent_executor.rs index 26acbc58a..d6dc14152 100644 --- a/test-integration/test-committor-service/tests/test_intent_executor.rs +++ b/test-integration/test-committor-service/tests/test_intent_executor.rs @@ -14,20 +14,23 @@ use futures::future::{join_all, try_join_all}; use magicblock_committor_program::pdas; use magicblock_committor_service::{ intent_executor::{ - error::{IntentExecutorError, TransactionStrategyExecutionError}, + accepted_intent_executor::AcceptedIntentExecutor, + error::IntentExecutorError, intent_execution_client::IntentExecutionClient, - task_info_fetcher::{ - CacheTaskInfoFetcher, RpcTaskInfoFetcher, TaskInfoFetcher, - TaskInfoFetcherError, + strategy_executor::{ + error::TransactionStrategyExecutionError, + two_stage::{Initialized, TwoStageStrategyExecutor}, + utils::prepare_and_execute_strategy, }, - two_stage_executor::{Initialized, TwoStageExecutor}, - utils::prepare_and_execute_strategy, ExecutionOutput, IntentExecutionReport, IntentExecutionResult, - IntentExecutor, IntentExecutorImpl, + IntentExecutor, IntentExecutorCtx, }, - persist::IntentPersisterImpl, tasks::{ task_builder::{TaskBuilderError, TaskBuilderImpl, TasksBuilder}, + task_info_fetcher::{ + CacheTaskInfoFetcher, RpcTaskInfoFetcher, TaskInfoFetcher, + TaskInfoFetcherError, + }, task_strategist::{TaskStrategist, TransactionStrategy}, }, transaction_preparator::{ @@ -68,7 +71,7 @@ use solana_sdk::{ use solana_sdk_ids::system_program; use crate::{ - common::{MockActionsCallbackExecutor, TestFixture}, + common::{MockActionsCallbackExecutor, MockOutboxClient, TestFixture}, utils::{ ensure_validator_authority, transactions::{ @@ -86,10 +89,11 @@ const ACTOR_ESCROW_INDEX: u8 = 1; struct TestEnv { fixture: TestFixture, task_info_fetcher: Arc>, - intent_executor: IntentExecutorImpl< + intent_executor: AcceptedIntentExecutor< TransactionPreparatorImpl, RpcTaskInfoFetcher, MockActionsCallbackExecutor, + MockOutboxClient, >, callback_executor: MockActionsCallbackExecutor, pre_test_tablemania_state: HashMap, @@ -115,11 +119,16 @@ impl TestEnv { } let callback_executor = MockActionsCallbackExecutor::default(); - let intent_executor = IntentExecutorImpl::new( - fixture.rpc_client.clone(), - transaction_preparator, - task_info_fetcher.clone(), - callback_executor.clone(), + let intent_executor = AcceptedIntentExecutor::new( + IntentExecutorCtx { + intent_client: IntentExecutionClient::new( + fixture.rpc_client.clone(), + ), + transaction_preparator, + task_info_fetcher: task_info_fetcher.clone(), + outbox_client: Arc::new(MockOutboxClient), + actions_callback_executor: callback_executor.clone(), + }, DEFAULT_ACTIONS_TIMEOUT, ); @@ -178,7 +187,6 @@ async fn test_commit_id_error_parsing() { &fixture.authority, &transaction_preparator, &mut transaction_strategy, - &None::, ) .await; assert!(execution_result.is_ok(), "Preparation is expected to pass!"); @@ -242,7 +250,6 @@ async fn test_undelegation_error_parsing() { &fixture.authority, &transaction_preparator, &mut transaction_strategy, - &None::, ) .await; assert!(execution_result.is_ok(), "Preparation is expected to pass!"); @@ -314,7 +321,6 @@ async fn test_action_error_parsing() { &fixture.authority, &transaction_preparator, &mut transaction_strategy, - &None::, ) .await; assert!(execution_result.is_ok(), "Preparation is expected to pass!"); @@ -383,7 +389,6 @@ async fn test_cpi_limits_error_parsing() { &fixture.authority, &transaction_preparator, &mut transaction_strategy, - &None::, ) .await; assert!(execution_result.is_ok(), "Preparation is expected to pass!"); @@ -417,7 +422,7 @@ async fn test_min_context_slot_not_reached_error_parsing() { let TestEnv { fixture: _, - mut intent_executor, + intent_executor, task_info_fetcher: _, callback_executor: _, pre_test_tablemania_state: _, @@ -433,9 +438,7 @@ async fn test_min_context_slot_not_reached_error_parsing() { true, ); - let execution_result = intent_executor - .execute(intent, None::) - .await; + let (execution_result, _) = Box::new(intent_executor).execute(intent).await; // Verify that we got MinContextSlotNotReachedError assert!(execution_result.inner.is_err()); @@ -464,7 +467,7 @@ async fn test_commit_id_error_recovery() { let TestEnv { fixture, - mut intent_executor, + intent_executor, task_info_fetcher, callback_executor: _, pre_test_tablemania_state, @@ -492,13 +495,12 @@ async fn test_commit_id_error_recovery() { assert!(res.unwrap().contains_key(&committed_account.pubkey)); // Now execute intent - let res = intent_executor - .execute(intent, None::) - .await; + let (res, cleanup_handle) = Box::new(intent_executor).execute(intent).await; let IntentExecutionResult { inner: res, patched_errors, callbacks_report, + .. } = res; assert!( @@ -518,7 +520,7 @@ async fn test_commit_id_error_recovery() { )); // Cleanup succeeds - assert!(intent_executor.cleanup().await.is_ok()); + assert!(cleanup_handle.clean().await.is_ok()); let mut commit_ids_by_pk = HashMap::new(); for el in [&committed_account].iter() { let nonce = task_info_fetcher @@ -544,7 +546,7 @@ async fn test_undelegation_error_recovery() { let TestEnv { fixture, - mut intent_executor, + intent_executor, task_info_fetcher: _, callback_executor: _, pre_test_tablemania_state, @@ -567,13 +569,12 @@ async fn test_undelegation_error_recovery() { let intent = create_intent(vec![committed_account.clone()], true); // Execute intent - let res = intent_executor - .execute(intent, None::) - .await; + let (res, cleanup_handle) = Box::new(intent_executor).execute(intent).await; let IntentExecutionResult { inner: res, patched_errors, callbacks_report, + .. } = res; assert!(res.is_ok()); @@ -589,7 +590,7 @@ async fn test_undelegation_error_recovery() { )); // Cleanup succeeds - assert!(intent_executor.cleanup().await.is_ok()); + assert!(cleanup_handle.clean().await.is_ok()); verify( &fixture.table_mania, fixture.rpc_client.get_inner(), @@ -606,7 +607,7 @@ async fn test_action_error_recovery() { let TestEnv { fixture, - mut intent_executor, + intent_executor, task_info_fetcher: _, callback_executor: _, pre_test_tablemania_state, @@ -634,13 +635,12 @@ async fn test_action_error_recovery() { }); let scheduled_intent = create_scheduled_intent(base_intent); - let res = intent_executor - .execute(scheduled_intent, None::) - .await; + let (res, _) = Box::new(intent_executor).execute(scheduled_intent).await; let IntentExecutionResult { inner: res, patched_errors, callbacks_report, + .. } = res; assert!(res.is_ok()); @@ -676,7 +676,7 @@ async fn test_commit_id_and_action_errors_recovery() { let TestEnv { fixture, - mut intent_executor, + intent_executor, task_info_fetcher, callback_executor: _, pre_test_tablemania_state, @@ -713,13 +713,13 @@ async fn test_commit_id_and_action_errors_recovery() { let scheduled_intent = create_scheduled_intent(base_intent); // Execute intent - let res = intent_executor - .execute(scheduled_intent, None::) - .await; + let (res, cleanup_handle) = + Box::new(intent_executor).execute(scheduled_intent).await; let IntentExecutionResult { inner: res, patched_errors, callbacks_report, + .. } = res; assert!(res.is_ok()); @@ -742,7 +742,7 @@ async fn test_commit_id_and_action_errors_recovery() { )); // Cleanup succeeds - assert!(intent_executor.cleanup().await.is_ok()); + assert!(cleanup_handle.clean().await.is_ok()); verify_committed_accounts_state( fixture.rpc_client.get_inner(), @@ -815,7 +815,6 @@ async fn test_cpi_limits_error_recovery() { scheduled_intent, strategy, &mut execution_report, - &None::, ) .await; assert!(execution_result.is_ok(), "Intent expected to recover"); @@ -938,7 +937,6 @@ async fn test_commit_id_actions_cpi_limit_errors_recovery() { scheduled_intent, strategy, &mut execution_report, - &None::, ) .await; @@ -1017,7 +1015,7 @@ async fn test_commit_id_actions_cpi_limit_errors_recovery() { async fn test_commit_unfinalized_account_recovery() { let TestEnv { fixture, - mut intent_executor, + intent_executor, task_info_fetcher: _, callback_executor: _, pre_test_tablemania_state: _, @@ -1072,9 +1070,7 @@ async fn test_commit_unfinalized_account_recovery() { remote_slot: Default::default(), }; let intent = create_intent(vec![committed_account], false); - let result = intent_executor - .execute(intent, None::) - .await; + let (result, _) = Box::new(intent_executor).execute(intent).await; assert!(result.inner.is_ok()); assert!(matches!( result.inner.unwrap(), @@ -1096,7 +1092,7 @@ async fn test_commit_unfinalized_account_recovery() { async fn test_commit_unfinalized_account_recovery_two_stage() { let TestEnv { fixture, - mut intent_executor, + intent_executor, task_info_fetcher: _, callback_executor: _, pre_test_tablemania_state: _, @@ -1159,9 +1155,7 @@ async fn test_commit_unfinalized_account_recovery_two_stage() { .collect(); let intent = create_intent(committed_accounts, true); - let result = intent_executor - .execute(intent, None::) - .await; + let (result, _) = Box::new(intent_executor).execute(intent).await; assert!(result.inner.is_ok()); assert!(matches!( result.inner.unwrap(), @@ -1188,7 +1182,7 @@ async fn test_action_callback_fired_on_failure() { let TestEnv { fixture, - mut intent_executor, + intent_executor, task_info_fetcher: _, callback_executor, pre_test_tablemania_state: _, @@ -1220,9 +1214,7 @@ async fn test_action_callback_fired_on_failure() { }); let scheduled_intent = create_scheduled_intent(base_intent); - let res = intent_executor - .execute(scheduled_intent, None::) - .await; + let (res, _) = Box::new(intent_executor).execute(scheduled_intent).await; assert!(res.inner.is_ok()); assert_eq!(res.callbacks_report.len(), 1, "1 callback scheduled"); @@ -1278,21 +1270,25 @@ async fn test_action_callback_fired_on_timeout() { let task_info_fetcher = Arc::new(CacheTaskInfoFetcher::new( RpcTaskInfoFetcher::new(fixture.rpc_client.clone()), )); - let mut intent_executor = IntentExecutorImpl::new( - fixture.rpc_client.clone(), - fixture.create_transaction_preparator(), - task_info_fetcher, - callback_executor.clone(), + let intent_executor = AcceptedIntentExecutor::new( + IntentExecutorCtx { + intent_client: IntentExecutionClient::new( + fixture.rpc_client.clone(), + ), + transaction_preparator: fixture.create_transaction_preparator(), + task_info_fetcher, + outbox_client: Arc::new(MockOutboxClient), + actions_callback_executor: callback_executor.clone(), + }, Duration::ZERO, ); let scheduled_intent = create_scheduled_intent(base_intent); - let res = intent_executor - .execute(scheduled_intent, None::) - .await; + let (res, cleanup_handle) = + Box::new(intent_executor).execute(scheduled_intent).await; assert!(res.inner.is_ok()); - assert!(res.patched_errors.is_empty()); + assert_eq!(res.patched_errors.len(), 1, "Action was supposed to fail"); assert_eq!(res.callbacks_report.len(), 1, "1 callback scheduled"); assert!(res.callbacks_report[0].is_ok(), "mock returns Ok(sig)"); @@ -1303,7 +1299,7 @@ async fn test_action_callback_fired_on_timeout() { assert_eq!(callbacks[0], expected_callback); assert!(matches!(result, Err(ActionError::TimeoutError))); - assert!(intent_executor.cleanup().await.is_ok()); + assert!(cleanup_handle.clean().await.is_ok()); verify_committed_accounts_state( fixture.rpc_client.get_inner(), &[committed_account], @@ -1380,7 +1376,6 @@ async fn test_callbacks_fired_in_two_stage() { &committed_pubkeys, &transaction_preparator, &task_info_fetcher, - &None::, ) .await .expect("commit must succeed"); @@ -1398,7 +1393,7 @@ async fn test_callbacks_fired_in_two_stage() { // Execute finalize stage let mut finalize_executor = executor.done(commit_sig); finalize_executor - .finalize(&transaction_preparator, &None::) + .finalize(&transaction_preparator) .await .expect("finalize must succeed"); @@ -1413,7 +1408,7 @@ async fn test_callbacks_fired_in_two_stage() { assert!(calls[1].1.is_ok()); } -/// Builds a [`TwoStageExecutor`] directly from an intent by constructing the +/// Builds a [`TwoStageStrategyExecutor`] directly from an intent by constructing the /// commit and finalize strategies independently, without going through /// `execute_inner` or any CPI-limit recovery path. async fn create_two_stage_executor<'a>( @@ -1422,36 +1417,31 @@ async fn create_two_stage_executor<'a>( intent: &ScheduledIntentBundle, task_info_fetcher: &Arc>, execution_report: &'a mut IntentExecutionReport, -) -> TwoStageExecutor<'a, MockActionsCallbackExecutor, Initialized> { +) -> TwoStageStrategyExecutor< + 'a, + MockActionsCallbackExecutor, + MockOutboxClient, + Initialized, +> { let authority = &fixture.authority.pubkey(); - let commit_tasks = TaskBuilderImpl::commit_tasks( - task_info_fetcher, - intent, - &None::, - ) - .await - .unwrap(); + let commit_tasks = TaskBuilderImpl::commit_tasks(task_info_fetcher, intent) + .await + .unwrap(); let finalize_tasks = TaskBuilderImpl::finalize_tasks(task_info_fetcher, intent) .await .unwrap(); - let commit_strategy = TaskStrategist::build_strategy( - commit_tasks, - authority, - &None::, - ) - .unwrap(); - let finalize_strategy = TaskStrategist::build_strategy( - finalize_tasks, - authority, - &None::, - ) - .unwrap(); - TwoStageExecutor::new( + let commit_strategy = + TaskStrategist::build_strategy(commit_tasks, authority).unwrap(); + let finalize_strategy = + TaskStrategist::build_strategy(finalize_tasks, authority).unwrap(); + let state = Initialized::new(commit_strategy, finalize_strategy); + TwoStageStrategyExecutor::new( + state, fixture.authority.insecure_clone(), - commit_strategy, - finalize_strategy, + intent.id, IntentExecutionClient::new(fixture.rpc_client.clone()), + Arc::new(MockOutboxClient), callback_executor.clone(), execution_report, ) @@ -1693,25 +1683,16 @@ async fn single_flow_transaction_strategy( task_info_fetcher: &Arc>, intent: &ScheduledIntentBundle, ) -> TransactionStrategy { - let mut tasks = TaskBuilderImpl::commit_tasks( - task_info_fetcher, - intent, - &None::, - ) - .await - .unwrap(); + let mut tasks = TaskBuilderImpl::commit_tasks(task_info_fetcher, intent) + .await + .unwrap(); let finalize_tasks = TaskBuilderImpl::finalize_tasks(task_info_fetcher, intent) .await .unwrap(); tasks.extend(finalize_tasks); - TaskStrategist::build_strategy( - tasks, - authority, - &None::, - ) - .unwrap() + TaskStrategist::build_strategy(tasks, authority).unwrap() } async fn verify_committed_accounts_state( diff --git a/test-integration/test-committor-service/tests/test_ix_commit_local.rs b/test-integration/test-committor-service/tests/test_ix_commit_local.rs index 72ae07121..e455cb1cb 100644 --- a/test-integration/test-committor-service/tests/test_ix_commit_local.rs +++ b/test-integration/test-committor-service/tests/test_ix_commit_local.rs @@ -4,14 +4,21 @@ use borsh::to_vec; use magicblock_committor_service::{ committor_processor::CommittorProcessor, config::ChainConfig, + intent_engine::db::DummyDB, intent_executor::{error::IntentExecutorError, ExecutionOutput}, - persist::CommitStrategy, + tasks::{ + commit_task::CommitDelivery, task_strategist::TransactionStrategy, + BaseTaskImpl, + }, ComputeBudgetConfig, }; use magicblock_core::intent::CommittedAccount; -use magicblock_program::magic_scheduled_base_intent::{ - CommitAndUndelegate, CommitType, MagicBaseIntent, MagicIntentBundle, - ScheduledIntentBundle, UndelegateType, +use magicblock_program::{ + magic_scheduled_base_intent::{ + CommitAndUndelegate, CommitType, MagicBaseIntent, MagicIntentBundle, + ScheduledIntentBundle, UndelegateType, + }, + outbox_intent_bundles::OutboxIntentBundle, }; use program_flexi_counter::state::FlexiCounter; use solana_account::{Account, ReadableAccount}; @@ -43,6 +50,108 @@ mod utils; // ----------------- type ExpectedStrategies = HashMap; +#[allow(unused)] +pub struct AccountCommitInfo { + data: Vec, + commit_nonce: u64, + allow_undelegation: bool, + strategy: CommitStrategy, +} + +impl AccountCommitInfo { + fn from_strategy( + strategy: TransactionStrategy, + ) -> Vec<(Pubkey, AccountCommitInfo)> { + let uses_alts = strategy.uses_alts(); + strategy + .optimized_tasks + .iter() + .fold(vec![], |mut infos, task| { + let commit_info = match task { + BaseTaskImpl::Commit(val) => { + let info = AccountCommitInfo { + commit_nonce: val.commit_id, + data: val.committed_account.account.data.clone(), + allow_undelegation: val.allow_undelegation, + strategy: CommitStrategy::new( + uses_alts, + val.delivery_details.clone(), + ), + }; + + Some((val.committed_account.pubkey, info)) + } + BaseTaskImpl::CommitFinalize(val) => { + let info = AccountCommitInfo { + commit_nonce: val.commit_id, + data: val.committed_account.account.data.clone(), + allow_undelegation: val.allow_undelegation, + strategy: CommitStrategy::new( + uses_alts, + val.delivery.clone(), + ), + }; + + Some((val.committed_account.pubkey, info)) + } + _ => None, + }; + + if let Some(commit_info) = commit_info { + infos.push(commit_info); + infos + } else { + infos + } + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum CommitStrategy { + /// Args without the use of a lookup table + #[default] + StateArgs, + /// Args with the use of a lookup table + StateArgsWithLookupTable, + /// Buffer and chunks which has the most overhead + StateBuffer, + /// Buffer and chunks with the use of a lookup table + StateBufferWithLookupTable, + + /// Args without the use of a lookup table + DiffArgs, + /// Args with the use of a lookup table + DiffArgsWithLookupTable, + /// Buffer and chunks which has the most overhead + DiffBuffer, + /// Buffer and chunks with the use of a lookup table + DiffBufferWithLookupTable, +} + +impl CommitStrategy { + fn new(uses_alts: bool, commit_delivery: CommitDelivery) -> Self { + match (uses_alts, commit_delivery) { + (false, CommitDelivery::StateInArgs) => Self::StateArgs, + (false, CommitDelivery::StateInBuffer { .. }) => Self::StateBuffer, + (false, CommitDelivery::DiffInArgs { .. }) => Self::DiffArgs, + (false, CommitDelivery::DiffInBuffer { .. }) => Self::DiffBuffer, + (true, CommitDelivery::StateInArgs) => { + Self::StateArgsWithLookupTable + } + (true, CommitDelivery::StateInBuffer { .. }) => { + Self::StateBufferWithLookupTable + } + (true, CommitDelivery::DiffInArgs { .. }) => { + Self::DiffArgsWithLookupTable + } + (true, CommitDelivery::DiffInBuffer { .. }) => { + Self::DiffBufferWithLookupTable + } + } + } +} + /// /// Unlike ScheduleCommitType which always implies Finalize (because that /// simulates "user-facing" schedule commit intent), CommitIntentKind simulates @@ -239,16 +348,14 @@ async fn commit_single_account( fund_validator_auth_and_ensure_validator_fees_vault(&validator_auth).await; // Run each test with and without finalizing - let processor = Arc::new( - CommittorProcessor::try_new( - validator_auth.insecure_clone(), - ":memory:", - ChainConfig::local(ComputeBudgetConfig::new(1_000_000)), - None, - common::MockActionsCallbackExecutor::default(), - ) - .unwrap(), - ); + let processor = Arc::new(CommittorProcessor::new( + validator_auth.insecure_clone(), + ChainConfig::local(ComputeBudgetConfig::new(1_000_000)), + None, + DummyDB::new(), + Arc::new(common::MockOutboxClient), + common::MockActionsCallbackExecutor::default(), + )); let counter_auth = Keypair::new(); let (pubkey, mut account) = @@ -323,16 +430,14 @@ async fn commit_book_order_account( fund_validator_auth_and_ensure_validator_fees_vault(&validator_auth).await; // Run each test with and without finalizing - let processor = Arc::new( - CommittorProcessor::try_new( - validator_auth.insecure_clone(), - ":memory:", - ChainConfig::local(ComputeBudgetConfig::new(1_000_000)), - None, - common::MockActionsCallbackExecutor::default(), - ) - .unwrap(), - ); + let processor = Arc::new(CommittorProcessor::new( + validator_auth.insecure_clone(), + ChainConfig::local(ComputeBudgetConfig::new(1_000_000)), + None, + DummyDB::new(), + Arc::new(common::MockOutboxClient), + common::MockActionsCallbackExecutor::default(), + )); let payer = Keypair::new(); let (order_book_pk, mut order_book_ac) = @@ -798,16 +903,14 @@ async fn commit_multiple_accounts( let validator_auth = ensure_validator_authority(); fund_validator_auth_and_ensure_validator_fees_vault(&validator_auth).await; - let processor = Arc::new( - CommittorProcessor::try_new( - validator_auth.insecure_clone(), - ":memory:", - ChainConfig::local(ComputeBudgetConfig::new(1_000_000)), - None, - common::MockActionsCallbackExecutor::default(), - ) - .unwrap(), - ); + let processor = Arc::new(CommittorProcessor::new( + validator_auth.insecure_clone(), + ChainConfig::local(ComputeBudgetConfig::new(1_000_000)), + None, + DummyDB::new(), + Arc::new(common::MockOutboxClient), + common::MockActionsCallbackExecutor::default(), + )); // Create bundles of committed accounts let bundles_of_committees = create_bundles(bundle_size, bytess).await; @@ -869,16 +972,14 @@ async fn execute_intent_bundle( let validator_auth = ensure_validator_authority(); fund_validator_auth_and_ensure_validator_fees_vault(&validator_auth).await; - let processor = Arc::new( - CommittorProcessor::try_new( - validator_auth.insecure_clone(), - ":memory:", - ChainConfig::local(ComputeBudgetConfig::new(1_000_000)), - None, - common::MockActionsCallbackExecutor::default(), - ) - .unwrap(), - ); + let processor = Arc::new(CommittorProcessor::new( + validator_auth.insecure_clone(), + ChainConfig::local(ComputeBudgetConfig::new(1_000_000)), + None, + DummyDB::new(), + Arc::new(common::MockOutboxClient), + common::MockActionsCallbackExecutor::default(), + )); // Create bundles of committed accounts let to_commit = create_and_delegate_accounts(bytess_to_commit); @@ -949,13 +1050,17 @@ async fn execute_intent_bundle( // Test Executor // ----------------- async fn ix_commit_local( - processor: Arc, + processor: Arc>, intent_bundles: Vec, expected_strategies: ExpectedStrategies, program_id: Pubkey, ) { + let outbox_bundles = intent_bundles + .iter() + .map(|b| OutboxIntentBundle::accepted(b.clone())) + .collect::>(); let execution_outputs = processor - .execute_intent_bundles(intent_bundles.clone()) + .execute_intent_bundles(outbox_bundles) .await .unwrap() .into_iter() @@ -1062,23 +1167,19 @@ async fn ix_commit_local( }) .collect(); - let statuses = processor.get_commit_statuses(base_intent.id).unwrap(); - debug!( - "{}", - statuses + let account_commit_infos: Vec<(Pubkey, AccountCommitInfo)> = + execution_result + .successful_transaction_strategies .iter() - .map(|x| x.to_string()) - .collect::>() - .join("\n") - ); + .flat_map(|s| AccountCommitInfo::from_strategy(s.clone())) + .collect(); - assert_eq!(statuses.len(), committed_accounts.len()); + assert_eq!(account_commit_infos.len(), committed_accounts.len()); - for commit_status in statuses { + for (pubkey, commit_info) in account_commit_infos { let (is_undelegate, account) = committed_accounts - .remove(&commit_status.pubkey) + .remove(&pubkey) .expect("Account should be persisted"); - println!("account: {}", account.pubkey); // When we finalize it is possible to also undelegate the account let expected_owner = if is_undelegate { @@ -1106,7 +1207,7 @@ async fn ix_commit_local( ); // Track the strategy used - let strategy = commit_status.commit_strategy; + let strategy = commit_info.strategy; let strategy_count = strategies.entry(strategy).or_insert(0); *strategy_count += 1; } diff --git a/test-integration/test-committor-service/tests/test_transaction_preparator.rs b/test-integration/test-committor-service/tests/test_transaction_preparator.rs index 207f3fc48..8ac829f6e 100644 --- a/test-integration/test-committor-service/tests/test_transaction_preparator.rs +++ b/test-integration/test-committor-service/tests/test_transaction_preparator.rs @@ -1,7 +1,6 @@ use borsh::BorshDeserialize; use magicblock_committor_program::Chunks; use magicblock_committor_service::{ - persist::IntentPersisterImpl, tasks::{ commit_task::CommitBufferStage, task_strategist::{TaskStrategist, TransactionStrategy}, @@ -56,11 +55,7 @@ async fn test_prepare_commit_tx_with_single_account() { // Test preparation let result = preparator - .prepare_for_strategy( - &fixture.authority, - &mut tx_strategy, - &None::, - ) + .prepare_for_strategy(&fixture.authority, &mut tx_strategy) .await; assert!(result.is_ok(), "Preparation failed: {:?}", result.err()); @@ -127,11 +122,7 @@ async fn test_prepare_commit_tx_with_multiple_accounts() { // Test preparation let mut actual_message = preparator - .prepare_for_strategy( - &fixture.authority, - &mut tx_strategy, - &None::, - ) + .prepare_for_strategy(&fixture.authority, &mut tx_strategy) .await .unwrap(); @@ -222,11 +213,7 @@ async fn test_prepare_commit_tx_with_base_actions() { // Test preparation let mut actual_message = preparator - .prepare_for_strategy( - &fixture.authority, - &mut tx_strategy, - &None::, - ) + .prepare_for_strategy(&fixture.authority, &mut tx_strategy) .await .unwrap(); @@ -303,11 +290,7 @@ async fn test_prepare_finalize_tx_with_undelegate_with_atls() { // Test preparation let result = preparator - .prepare_for_strategy( - &fixture.authority, - &mut tx_strategy, - &None::, - ) + .prepare_for_strategy(&fixture.authority, &mut tx_strategy) .await; assert!(result.is_ok()); diff --git a/test-integration/test-schedule-intent/Cargo.toml b/test-integration/test-schedule-intent/Cargo.toml index 6f1140712..cb2e279fd 100644 --- a/test-integration/test-schedule-intent/Cargo.toml +++ b/test-integration/test-schedule-intent/Cargo.toml @@ -7,12 +7,24 @@ version.workspace = true log = "0.4.29" [dev-dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } ephemeral-rollups-sdk = { workspace = true } integration-test-tools = { workspace = true } +magicblock-committor-service = { workspace = true, features = ["dev-context-only-utils"] } +magicblock-core = { workspace = true } magicblock-delegation-program-api = { workspace = true } magicblock-magic-program-api = { workspace = true } +magicblock-program = { workspace = true } +magicblock-rpc-client = { workspace = true } +magicblock-table-mania = { workspace = true } program-flexi-counter = { workspace = true, features = ["no-entrypoint"] } +solana-commitment-config = { workspace = true } +serde_json = { workspace = true } +solana-rpc-client = { workspace = true } solana-rpc-client-api = { workspace = true } solana-sdk = { workspace = true } +serial_test = { workspace = true } test-kit = { workspace = true } +tokio = { workspace = true } tracing = { workspace = true } diff --git a/test-integration/test-schedule-intent/tests/common/mod.rs b/test-integration/test-schedule-intent/tests/common/mod.rs new file mode 100644 index 000000000..d12312893 --- /dev/null +++ b/test-integration/test-schedule-intent/tests/common/mod.rs @@ -0,0 +1,170 @@ +use dlp_api::pda::ephemeral_balance_pda_from_payer; +use integration_test_tools::IntegrationTestContext; +use program_flexi_counter::{ + delegation_program_id, + instruction::{create_add_ix, create_delegate_ix, create_init_ix}, + state::FlexiCounter, +}; +use solana_sdk::{ + native_token::LAMPORTS_PER_SOL, pubkey::Pubkey, rent::Rent, + signature::Keypair, signer::Signer, +}; + +pub const LABEL: &str = "I am a label"; +pub const BASE_ACTION_FEE: u64 = 5000; +pub const CALLBACK_FEE: u64 = 5000; + +pub fn setup_payer(ctx: &IntegrationTestContext) -> Keypair { + let payer = Keypair::new(); + ctx.airdrop_chain(&payer.pubkey(), LAMPORTS_PER_SOL) + .unwrap(); + + let ix = dlp_api::instruction_builder::top_up_ephemeral_balance( + payer.pubkey(), + payer.pubkey(), + Some(LAMPORTS_PER_SOL / 2), + Some(1), + ); + ctx.send_and_confirm_instructions_with_payer_chain(&[ix], &payer) + .unwrap(); + + let escrow_pda = ephemeral_balance_pda_from_payer(&payer.pubkey(), 1); + let rent = Rent::default().minimum_balance(0); + assert_eq!( + ctx.fetch_chain_account(escrow_pda).unwrap().lamports, + LAMPORTS_PER_SOL / 2 + rent + ); + + payer +} + +pub fn init_counter(ctx: &IntegrationTestContext, payer: &Keypair) { + let ix = create_init_ix(payer.pubkey(), LABEL.to_string()); + let (_, confirmed) = ctx + .send_and_confirm_instructions_with_payer_chain(&[ix], payer) + .unwrap(); + assert!(confirmed, "Should confirm transaction"); + + let counter_pda = FlexiCounter::pda(&payer.pubkey()).0; + let counter = ctx + .fetch_chain_account_struct::(counter_pda) + .unwrap(); + assert_eq!( + counter, + FlexiCounter { + count: 0, + updates: 0, + label: LABEL.to_string() + }, + ) +} + +pub fn delegate_counter(ctx: &IntegrationTestContext, payer: &Keypair) { + ctx.wait_for_next_slot_ephem().unwrap(); + + let counter_pda = FlexiCounter::pda(&payer.pubkey()).0; + let ix = create_delegate_ix(payer.pubkey()); + ctx.send_and_confirm_instructions_with_payer_chain(&[ix], payer) + .unwrap(); + + let owner = ctx.fetch_chain_account_owner(counter_pda).unwrap(); + assert_eq!(owner, delegation_program_id()); +} + +pub fn add_to_counter( + ctx: &IntegrationTestContext, + payer: &Keypair, + value: u8, +) { + ctx.wait_for_next_slot_ephem().unwrap(); + + let counter_pda = FlexiCounter::pda(&payer.pubkey()).0; + let counter_before = ctx + .fetch_ephem_account_struct::(counter_pda) + .unwrap_or(FlexiCounter { + count: 0, + updates: 0, + label: LABEL.to_string(), + }); + + let ix = create_add_ix(payer.pubkey(), value); + ctx.send_and_confirm_instructions_with_payer_ephem(&[ix], payer) + .unwrap(); + + let counter = ctx + .fetch_ephem_account_struct::(counter_pda) + .unwrap(); + assert_eq!( + counter, + FlexiCounter { + count: counter_before.count + value as u64, + updates: counter_before.updates + 1, + label: LABEL.to_string() + }, + ) +} + +#[allow(unused)] +pub struct ExpectedCounter { + pub pda: Pubkey, + pub expected: u64, +} + +#[allow(unused)] +pub fn assert_counters( + ctx: &IntegrationTestContext, + expected_counters: &[ExpectedCounter], + is_base: bool, +) { + let actual = expected_counters + .iter() + .map(|c| { + if is_base { + ctx.fetch_chain_account_struct::(c.pda) + .unwrap() + } else { + ctx.fetch_ephem_account_struct::(c.pda) + .unwrap() + } + }) + .collect::>(); + + for (i, actual_counter) in actual.iter().enumerate() { + assert_eq!(actual_counter.count, expected_counters[i].expected); + } +} + +#[allow(unused)] +pub fn verify_undelegation_in_ephem_via_owner( + pubkeys: &[Pubkey], + ctx: &IntegrationTestContext, +) { + const RETRY_LIMIT: usize = 20; + let mut retries = 0; + + loop { + ctx.wait_for_next_slot_ephem().unwrap(); + let mut not_verified = vec![]; + for pk in pubkeys.iter() { + let counter_pda = FlexiCounter::pda(pk).0; + let owner = ctx.fetch_ephem_account_owner(counter_pda).unwrap(); + if owner == delegation_program_id() { + not_verified.push(*pk); + } + } + if not_verified.is_empty() { + break; + } + retries += 1; + if retries >= RETRY_LIMIT { + panic!( + "Failed to verify undelegation for pubkeys: {}", + not_verified + .iter() + .map(|k| k.to_string()) + .collect::>() + .join(", ") + ); + } + } +} diff --git a/test-integration/test-schedule-intent/tests/test_outbox_flow.rs b/test-integration/test-schedule-intent/tests/test_outbox_flow.rs new file mode 100644 index 000000000..4f31a561e --- /dev/null +++ b/test-integration/test-schedule-intent/tests/test_outbox_flow.rs @@ -0,0 +1,1140 @@ +mod common; +use std::{ + sync::{Arc, Mutex, Once}, + time::Duration, +}; + +use anyhow::anyhow; +use async_trait::async_trait; +use common::*; +use integration_test_tools::{ + loaded_accounts::DLP_TEST_AUTHORITY_BYTES, IntegrationTestContext, +}; +use magicblock_committor_service::{ + intent_executor::{ + accepted_intent_executor::AcceptedIntentExecutor, + build_stage_intent_executor, error::IntentExecutorResult, + intent_execution_client::IntentExecutionClient, ExecutionOutput, + IntentExecutionReport, IntentExecutor, IntentExecutorCtx, + }, + outbox::{ + outbox_client::InternalOutboxClientError, + outbox_intent_bundles_reader::OutboxIntentBundlesReader, + IntentSentTransaction, OutboxClient, ScheduledBaseIntentMeta, + }, + tasks::task_info_fetcher::{CacheTaskInfoFetcher, RpcTaskInfoFetcher}, + transaction_preparator::TransactionPreparatorImpl, + ComputeBudgetConfig, DEFAULT_ACTIONS_TIMEOUT, +}; +use magicblock_core::{ + intent::{outbox::outbox_intent_pda, BaseActionCallback}, + traits::{ + ActionError, ActionResult, ActionsCallbackScheduler, + CallbackScheduleError, + }, +}; +use magicblock_magic_program_api::{ + args::{ + CommitAndUndelegateArgs, CommitTypeArgs, MagicIntentBundleArgs, + UndelegateTypeArgs, + }, + instruction::MagicBlockInstruction, + outbox::{ExecutionStage, TwoStageProgress}, + MAGIC_CONTEXT_PUBKEY, +}; +use magicblock_program::{ + instruction_utils::InstructionUtils, + magic_scheduled_base_intent::ScheduledIntentBundle, + outbox_intent_bundles::{OutboxIntentBundle, OutboxIntentBundleStatus}, + validator::init_validator_authority, + MagicContext, +}; +use magicblock_rpc_client::MagicblockRpcClient; +use magicblock_table_mania::{GarbageCollectorConfig, TableMania}; +use program_flexi_counter::{ + instruction::create_transfer_intent_ix, state::FlexiCounter, +}; +use serial_test::serial; +use solana_rpc_client::{ + http_sender::HttpSender, + nonblocking::rpc_client::RpcClient as AsyncRpcClient, + rpc_client::RpcClientConfig, + rpc_sender::{RpcSender, RpcTransportStats}, +}; +use solana_rpc_client_api::{client_error, request::RpcRequest}; +use solana_sdk::{ + instruction::{AccountMeta, Instruction}, + pubkey::Pubkey, + signature::{Keypair, Signature}, + signer::Signer, + transaction::Transaction, +}; + +type CallbackRecord = + (Vec, Option, ActionResult); + +type TestIntentExecutorCtx = IntentExecutorCtx< + TransactionPreparatorImpl, + RpcTaskInfoFetcher, + RecordingCallbackScheduler, + TestOutboxClient, +>; + +struct TestEnv { + ctx: IntegrationTestContext, + chain_mb_client: MagicblockRpcClient, + ephem_rpc: Arc, + intent_client: IntentExecutionClient, + table_mania: TableMania, + task_info_fetcher: Arc>, + callback_scheduler: RecordingCallbackScheduler, +} + +impl TestEnv { + async fn setup() -> Self { + let validator_authority = ensure_validator_authority(); + let ctx = IntegrationTestContext::try_new().unwrap(); + let chain_rpc = Arc::new(ctx.try_chain_client_async().unwrap()); + let ephem_rpc = Arc::new(ctx.try_ephem_client_async().unwrap()); + let chain_mb_rpc = MagicblockRpcClient::new(chain_rpc); + + let intent_client = IntentExecutionClient::new(chain_mb_rpc.clone()); + let gc_config = GarbageCollectorConfig::default(); + let table_mania = TableMania::new( + chain_mb_rpc.clone(), + &validator_authority, + Some(gc_config), + ); + let task_info_fetcher = Arc::new(CacheTaskInfoFetcher::new( + RpcTaskInfoFetcher::new(chain_mb_rpc.clone()), + )); + + Self { + ctx, + chain_mb_client: chain_mb_rpc, + ephem_rpc, + intent_client, + table_mania, + task_info_fetcher, + callback_scheduler: RecordingCallbackScheduler::default(), + } + } + + fn executor_ctx_builder(&self) -> TestIntentExecutorCtxBuilder { + let ctx = TestIntentExecutorCtx { + intent_client: self.intent_client.clone(), + transaction_preparator: self.transaction_preparator(), + task_info_fetcher: self.task_info_fetcher.clone(), + outbox_client: self.outbox_client().into(), + actions_callback_executor: self.callback_scheduler.clone(), + }; + TestIntentExecutorCtxBuilder { ctx } + } + + fn outbox_client(&self) -> TestOutboxClient { + TestOutboxClient::new(self.ephem_rpc.clone()) + } + + fn transaction_preparator(&self) -> TransactionPreparatorImpl { + let compute_budget = ComputeBudgetConfig::new(1_000_000); + TransactionPreparatorImpl::new( + self.chain_mb_client.clone(), + self.table_mania.clone(), + compute_budget, + ) + } + + fn intent_client_with_send_sleep( + &self, + sleep_duration: Duration, + ) -> IntentExecutionClient { + let sender = SleepyRpcSender { + inner: HttpSender::new(IntegrationTestContext::url_chain()), + sleep_duration, + }; + let rpc_client = Arc::new(AsyncRpcClient::new_sender( + sender, + RpcClientConfig::with_commitment(self.ctx.commitment), + )); + IntentExecutionClient::new(MagicblockRpcClient::new(rpc_client)) + } +} + +pub struct TestIntentExecutorCtxBuilder { + ctx: TestIntentExecutorCtx, +} + +impl TestIntentExecutorCtxBuilder { + fn with_outbox_client(mut self, value: Arc) -> Self { + self.ctx.outbox_client = value; + self + } + + fn with_intent_client(mut self, value: IntentExecutionClient) -> Self { + self.ctx.intent_client = value; + self + } + + fn build(self) -> TestIntentExecutorCtx { + self.ctx + } +} + +/// Schedyles commit of counters directly via magic-program using validator authority +fn schedule_commit_finalize(ctx: &IntegrationTestContext, counters: &[Pubkey]) { + let validator_keypair = ensure_validator_authority(); + + let schedule_ix = schedule_commit_instruction( + &validator_keypair.pubkey(), + counters.to_vec(), + ); + let mut tx = Transaction::new_with_payer( + &[schedule_ix], + Some(&validator_keypair.pubkey()), + ); + let sig = ctx + .send_transaction_ephem(&mut tx, &[&validator_keypair]) + .unwrap(); + println!("schedule_commit_finalize sig: {}", sig); +} + +fn schedule_intent_with_callback( + ctx: &IntegrationTestContext, + payer: &Keypair, + destination: Pubkey, + amount: u64, +) { + let schedule_ix = create_transfer_intent_ix( + payer.pubkey(), + destination, + ctx.ephem_validator_identity.unwrap(), + amount, + false, + 100_000, + ); + let mut tx = + Transaction::new_with_payer(&[schedule_ix], Some(&payer.pubkey())); + let sig = ctx.send_transaction_ephem(&mut tx, &[payer]).unwrap(); + + println!("schedule_intent_with_callback sig: {}", sig); +} + +/// Tries to accept intent +fn steal_accept_intent( + ctx: &IntegrationTestContext, + intent_id: u64, +) -> Result<(), anyhow::Error> { + let validator_keypair = ensure_validator_authority(); + + let accept_ix = + InstructionUtils::accept_scheduled_commits_instruction([intent_id]); + let mut tx = Transaction::new_with_payer( + &[accept_ix], + Some(&validator_keypair.pubkey()), + ); + + let (sig, confirmed) = + ctx.send_and_confirm_transaction_ephem(&mut tx, &[&validator_keypair])?; + + if !confirmed { + Err(anyhow!("tx not confirmed: {}", sig)) + } else { + println!("accept sig: {}", sig); + Ok(()) + } +} + +fn steal_schedule_accept_intent( + ctx: &IntegrationTestContext, + schedule: impl Fn(&IntegrationTestContext), +) -> u64 { + const MAX_ATTEMPTS: u8 = 5; + + let mut attempt = 0; + loop { + let intent_id = read_next_intent_id(ctx); + schedule(ctx); + let result = steal_accept_intent(ctx, intent_id); + match result { + Ok(()) => return intent_id, + Err(err) => { + println!("Failed to steal"); + + if attempt >= MAX_ATTEMPTS { + panic!("Failed to steal intent: {}", err); + } + attempt += 1; + } + } + } +} + +fn schedule_and_accept( + ctx: &IntegrationTestContext, + schedule: impl Fn(&IntegrationTestContext), +) -> OutboxIntentBundle { + ctx.wait_for_next_slot_ephem().unwrap(); + + let intent_id = steal_schedule_accept_intent(ctx, schedule); + let pda = outbox_intent_pda(intent_id); + let data = ctx.fetch_ephem_account_data(pda).unwrap(); + OutboxIntentBundle::try_from_bytes(&data).unwrap() +} + +/// AcceptedIntentExecutor drives the full commit flow using TestOutboxClient. +/// Verifies: executor succeeds, set_intent_execution_stage is called, +/// and the committed value lands on the base chain. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_pickup_executed_intent() { + let test_env = TestEnv::setup().await; + + let payer = setup_payer(&test_env.ctx); + let counter_pda = FlexiCounter::pda(&payer.pubkey()).0; + + init_counter(&test_env.ctx, &payer); + delegate_counter(&test_env.ctx, &payer); + add_to_counter(&test_env.ctx, &payer, 42); + + let outbox_bundle = schedule_and_accept(&test_env.ctx, |ctx| { + schedule_commit_finalize(ctx, &[counter_pda]) + }); + + // Execute intent + let outbox_client = Arc::new(test_env.outbox_client()); + let executor_ctx = test_env + .executor_ctx_builder() + .with_outbox_client(outbox_client.clone()) + .build(); + let executor = + AcceptedIntentExecutor::new(executor_ctx, DEFAULT_ACTIONS_TIMEOUT); + let (result, cleanup_handle) = Box::new(executor) + .execute(outbox_bundle.inner.clone()) + .await; + assert!(result.inner.is_ok(), "Executor failed: {:?}", result.inner); + assert_eq!( + outbox_client.sent_commits.lock().unwrap().as_slice(), + [(outbox_bundle.inner.id, true)], + "notify_commit_sent should have been called once with success" + ); + + cleanup_handle.clean().await.expect("cleanup failed"); + + // Simulates shutdown/crash after successful execution + // Validator could sent tx and then crash + // On restart validator will extract outbox bundles + let outbox_bundle = test_env + .outbox_client() + .outbox_reader() + .fetch_outbox_intent(outbox_bundle.inner.id) + .await + .expect("fetch failed") + .expect("outbox bundle not found"); + + let ExecutionOutput::SingleStage(signature) = result.inner.unwrap() else { + panic!("Unexpected execution strategy"); + }; + match outbox_bundle.status() { + OutboxIntentBundleStatus::Executing(ExecutionStage::SingleStage( + pending, + )) => { + assert_eq!(pending.signature, signature, "Invalid outbox state"); + } + other => panic!("Invalid outbox state: {other:?}"), + } + // Builder executor + let executor = build_stage_intent_executor( + test_env.executor_ctx_builder().build(), + outbox_bundle.status().clone(), + DEFAULT_ACTIONS_TIMEOUT, + ); + let (result, cleanup_handle) = Box::new(executor) + .execute(outbox_bundle.inner.clone()) + .await; + let ExecutionOutput::SingleStage(retried_signature) = result.inner.unwrap() + else { + panic!("Unexpected execution strategy"); + }; + assert_eq!( + signature, retried_signature, + "Execution shouldn't be retried" + ); + cleanup_handle.clean().await.expect("cleanup failed"); + + // Validate on chain state + let counter = test_env + .ctx + .fetch_chain_account_struct::(counter_pda) + .unwrap(); + assert_eq!(counter.count, 42); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_pickup_failed_intent() { + let test_env = TestEnv::setup().await; + + let payer = setup_payer(&test_env.ctx); + let counter_pda = FlexiCounter::pda(&payer.pubkey()).0; + + init_counter(&test_env.ctx, &payer); + delegate_counter(&test_env.ctx, &payer); + add_to_counter(&test_env.ctx, &payer, 42); + let outbox_bundle = schedule_and_accept(&test_env.ctx, |ctx| { + schedule_commit_finalize(ctx, &[counter_pda]); + }); + let intent_id = outbox_bundle.id; + + // Create executor that will fail before chain execution + let mut failing_outbox = test_env.outbox_client(); + failing_outbox.with_fail_execution_stage(true); + + let executor_ctx = test_env + .executor_ctx_builder() + .with_outbox_client(failing_outbox.into()) + .build(); + + let executor = Box::new(AcceptedIntentExecutor::new( + executor_ctx, + DEFAULT_ACTIONS_TIMEOUT, + )); + let (result, cleanup_handle) = executor.execute(outbox_bundle.inner).await; + assert!(result + .inner + .unwrap_err() + .to_string() + .contains("set_intent_execution_stage failed")); + cleanup_handle + .clean() + .await + .expect("Fail must succeed even after failure"); + + // Verify chain status is still accepted + let chain_outbox_bundle = test_env + .outbox_client() + .outbox_reader() + .fetch_outbox_intent(intent_id) + .await + .expect("fetch suceeds") + .expect("outbox intent exists"); + assert_eq!( + chain_outbox_bundle.status(), + &OutboxIntentBundleStatus::Accepted, + "Invalid outbox state" + ); + + // Build executor that will drive execution to success + let executor_ctx = test_env.executor_ctx_builder().build(); + let executor = Box::new(AcceptedIntentExecutor::new( + executor_ctx, + DEFAULT_ACTIONS_TIMEOUT, + )); + let (result, cleanup_handle) = + executor.execute(chain_outbox_bundle.inner).await; + let ExecutionOutput::SingleStage(_) = + result.inner.expect("execution succeeded") + else { + panic!("Unexpected execution strategy"); + }; + cleanup_handle.clean().await.expect("cleanup failed"); + + // Validate on chain state + let counter = test_env + .ctx + .fetch_chain_account_struct::(counter_pda) + .unwrap(); + assert_eq!(counter.count, 42); +} + +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_pickup_after_timeout() { + const ACTIONS_TIMEOUT: Duration = Duration::from_millis(15); + /// Larger than `ACTIONS_TIMEOUT` in order to trigger timeout int `execute_with_timeout` + const SET_EXECUTION_STAGE_SLEEP: Duration = Duration::from_millis(20); + const TRANSFER_AMOUNT: u64 = 900_000; + + let test_env = TestEnv::setup().await; + + let payer = setup_payer(&test_env.ctx); + let chain_payer = setup_payer(&test_env.ctx); + + init_counter(&test_env.ctx, &payer); + delegate_counter(&test_env.ctx, &payer); + add_to_counter(&test_env.ctx, &payer, 42); + test_env.ctx.delegate_account(&chain_payer, &payer).unwrap(); + + let destination = Keypair::new(); + let payer_balance_before = test_env + .ctx + .fetch_ephem_account_balance(&payer.pubkey()) + .unwrap(); + let outbox_bundle = schedule_and_accept(&test_env.ctx, |ctx| { + schedule_intent_with_callback( + ctx, + &payer, + destination.pubkey(), + TRANSFER_AMOUNT, + ); + }); + + let mut slow_outbox_client = test_env.outbox_client(); + slow_outbox_client + .with_set_execution_stage_sleep(SET_EXECUTION_STAGE_SLEEP); + let executor_ctx = test_env + .executor_ctx_builder() + .with_outbox_client(slow_outbox_client.into()) + .build(); + + let executor = + Box::new(AcceptedIntentExecutor::new(executor_ctx, ACTIONS_TIMEOUT)); + let (result, cleanup_handle) = + executor.execute(outbox_bundle.inner.clone()).await; + assert!(result.inner.is_ok(), "Executor failed: {:?}", result.inner); + cleanup_handle.clean().await.expect("cleanup failed"); + + // Callback was triggered with TimeoutError (set_intent_execution_stage exceeded ACTIONS_TIMEOUT) + let callback_calls = test_env.callback_scheduler.recorded_calls(); + assert_eq!( + callback_calls.len(), + 1, + "Expected exactly one callback invocation" + ); + let (callbacks, signature, cb_result) = &callback_calls[0]; + assert!(!callbacks.is_empty(), "Callback list must not be empty"); + assert!(signature.is_none(), "No signature on timeout"); + assert!( + matches!(cb_result, Err(ActionError::TimeoutError)), + "Expected TimeoutError, got: {:?}", + cb_result + ); + + // Despite the callback timeout the commit tx still lands on chain + let payer_balance_after = test_env + .ctx + .fetch_ephem_account_balance(&payer.pubkey()) + .unwrap(); + assert_eq!( + payer_balance_after + BASE_ACTION_FEE + CALLBACK_FEE + TRANSFER_AMOUNT, + payer_balance_before, + "Payer fees not deducted correctly" + ); + let dest_balance = test_env + .ctx + .fetch_chain_account_balance(&destination.pubkey()) + .unwrap(); + assert_eq!( + dest_balance, TRANSFER_AMOUNT, + "Destination did not receive funds" + ); +} + +/// Similar to `test_pickup_after_timeout` but the slow-down is at the RpcSender +/// level rather than at `set_intent_execution_stage`. +/// +/// Timeline inside `stage_execution_loop`: +/// 1. getLatestBlockhash → HTTP response + SEND_SLEEP (~80ms) → ok +/// 2. set_intent_execution_stage (TestOutboxClient, instant) +/// 3. pending_signature = Some(sig) +/// 4. sendTransaction → HTTP response (tx IS on chain) + SEND_SLEEP starts +/// → ACTIONS_TIMEOUT fires during that sleep +/// +/// ACTIONS_TIMEOUT (100ms) sits between one SEND_SLEEP (80ms) and two (160ms), +/// so the tx is always submitted before the timeout, but the callback gets +/// TimeoutError with no signature. On re-execution the executor finds +/// pending_signature set, confirms the tx succeeded, and returns without +/// re-submitting. +/// TODO(edwin): add same but where tx wasn't actually sent and we timedout +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_pick_up_after_tx_submission() { + const SEND_SLEEP: Duration = Duration::from_millis(80); + const ACTIONS_TIMEOUT: Duration = Duration::from_millis(100); + const TRANSFER_AMOUNT: u64 = 900_000; + + let test_env = TestEnv::setup().await; + + let payer = setup_payer(&test_env.ctx); + let chain_payer = setup_payer(&test_env.ctx); + + init_counter(&test_env.ctx, &payer); + delegate_counter(&test_env.ctx, &payer); + add_to_counter(&test_env.ctx, &payer, 42); + test_env.ctx.delegate_account(&chain_payer, &payer).unwrap(); + + let destination = Keypair::new(); + let payer_balance_before = test_env + .ctx + .fetch_ephem_account_balance(&payer.pubkey()) + .unwrap(); + let outbox_bundle = schedule_and_accept(&test_env.ctx, |ctx| { + schedule_intent_with_callback( + ctx, + &payer, + destination.pubkey(), + TRANSFER_AMOUNT, + ); + }); + + let slow_intent_client = test_env.intent_client_with_send_sleep(SEND_SLEEP); + let executor_ctx = test_env + .executor_ctx_builder() + .with_intent_client(slow_intent_client) + .build(); + + let executor = + Box::new(AcceptedIntentExecutor::new(executor_ctx, ACTIONS_TIMEOUT)); + let (result, cleanup_handle) = + executor.execute(outbox_bundle.inner.clone()).await; + assert!(result.inner.is_ok(), "Executor failed: {:?}", result.inner); + cleanup_handle.clean().await.expect("cleanup failed"); + + // Callback triggered with TimeoutError — tx was submitted before timeout. + let callback_calls = test_env.callback_scheduler.recorded_calls(); + assert_eq!( + callback_calls.len(), + 1, + "Expected exactly one callback invocation" + ); + let (callbacks, signature, cb_result) = &callback_calls[0]; + assert!(!callbacks.is_empty(), "Callback list must not be empty"); + assert!(signature.is_none(), "No signature on timeout"); + assert!( + matches!(cb_result, Err(ActionError::TimeoutError)), + "Expected TimeoutError, got: {:?}", + cb_result + ); + + // Despite the callback timeout the commit tx still lands on chain. + let payer_balance_after = test_env + .ctx + .fetch_ephem_account_balance(&payer.pubkey()) + .unwrap(); + assert_eq!( + payer_balance_after + BASE_ACTION_FEE + CALLBACK_FEE + TRANSFER_AMOUNT, + payer_balance_before, + "Payer fees not deducted correctly" + ); + let dest_balance = test_env + .ctx + .fetch_chain_account_balance(&destination.pubkey()) + .unwrap(); + assert_eq!( + dest_balance, TRANSFER_AMOUNT, + "Destination did not receive funds" + ); +} + +/// Verifies recovery when the validator crashes after the commit tx lands on chain +/// but before the finalize stage is recorded in the outbox. +/// +/// Flow: +/// 1. AcceptedIntentExecutor runs TwoStage; commit stage completes (tx on chain, +/// outbox updated to TwoStage::Committing). +/// 2. set_intent_execution_stage for the Finalizing stage fails → executor errors +/// and finalize tx is never sent. +/// 3. On restart, build_stage_intent_executor sees TwoStage::Committing, confirms +/// the commit sig on chain, then executes the finalize stage. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_pickup_after_committing() { + const COUNTER_COUNT: usize = 9; + + let test_env = TestEnv::setup().await; + let counters = setup_counters(&test_env.ctx, COUNTER_COUNT); + let counter_pdas: Vec = + counters.iter().map(|(_, pda)| *pda).collect(); + + let outbox_bundle = schedule_and_accept(&test_env.ctx, |ctx| { + schedule_commit_and_undelegate_bundle(ctx, &counter_pdas); + }); + + // Fail only when the executor tries to record the Finalizing stage. + // The commit stage (Committing) is allowed through so the commit tx is sent. + let mut failing_outbox = test_env.outbox_client(); + failing_outbox.with_fail_finalizing(true); + let stage_calls = failing_outbox.stage_calls.clone(); + + let executor_ctx = test_env + .executor_ctx_builder() + .with_outbox_client(failing_outbox.into()) + .build(); + + let executor = Box::new(AcceptedIntentExecutor::new( + executor_ctx, + DEFAULT_ACTIONS_TIMEOUT, + )); + let (result, cleanup_handle) = + executor.execute(outbox_bundle.inner.clone()).await; + assert!( + result.inner.is_err(), + "Expected failure on set_intent_execution_stage(Finalizing)" + ); + cleanup_handle.clean().await.expect("cleanup after failure"); + + // Commit stage was recorded; outbox is TwoStage::Committing + let commit_sig = { + let calls = stage_calls.lock().unwrap(); + assert_eq!(calls.len(), 1, "Only the commit stage should be recorded"); + let commit_sig = match &calls[0].1 { + ExecutionStage::TwoStage(TwoStageProgress::Committing(sig)) => *sig, + other => panic!("Expected Committing stage, got {:?}", other), + }; + commit_sig + }; + + // Re-fetch outbox and confirm it reflects TwoStage::Committing + let outbox_bundle = test_env + .outbox_client() + .outbox_reader() + .fetch_outbox_intent(outbox_bundle.inner.id) + .await + .expect("fetch succeeded") + .expect("outbox bundle present"); + assert!( + matches!( + outbox_bundle.status(), + OutboxIntentBundleStatus::Executing(ExecutionStage::TwoStage( + TwoStageProgress::Committing(_) + )) + ), + "Expected TwoStage::Committing in outbox, got {:?}", + outbox_bundle.status() + ); + + // Recovery: build_stage_intent_executor detects the commit sig on chain and + // runs the finalize stage without re-submitting the commit tx. + let executor = build_stage_intent_executor( + test_env.executor_ctx_builder().build(), + outbox_bundle.status().clone(), + DEFAULT_ACTIONS_TIMEOUT, + ); + let (result, cleanup_handle) = Box::new(executor) + .execute(outbox_bundle.inner.clone()) + .await; + let ExecutionOutput::TwoStage { + commit_signature, + finalize_signature: _, + } = result.inner.expect("recovery execution succeeded") + else { + panic!("Expected TwoStage output"); + }; + assert_eq!( + commit_signature, commit_sig.signature, + "Commit sig must not change on recovery" + ); + cleanup_handle + .clean() + .await + .expect("cleanup after recovery"); + + // Counters should be finalized (undelegated) on chain + for (_, pda) in &counters { + let counter = test_env + .ctx + .fetch_chain_account_struct::(*pda) + .unwrap(); + assert!(counter.count > 0, "counter should be committed on chain"); + } +} + +/// Verifies recovery when the validator crashes after both the commit and +/// finalize txs have landed but before the service can act on the result. +/// +/// Flow: +/// 1. AcceptedIntentExecutor runs TwoStage to completion; outbox is updated to +/// TwoStage::Finalizing with both signatures. +/// 2. On restart, build_stage_intent_executor sees TwoStage::Finalizing, confirms +/// the finalize sig on chain, and returns the same signatures without +/// re-submitting anything. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn test_pickup_after_finalizing() { + const COUNTER_COUNT: usize = 9; + + let test_env = TestEnv::setup().await; + let counters = setup_counters(&test_env.ctx, COUNTER_COUNT); + let counter_pdas: Vec = + counters.iter().map(|(_, pda)| *pda).collect(); + + let outbox_bundle = schedule_and_accept(&test_env.ctx, |ctx| { + schedule_commit_and_undelegate_bundle(ctx, &counter_pdas); + }); + + // Run to completion with a normal outbox client + let executor_ctx = test_env.executor_ctx_builder().build(); + let executor = Box::new(AcceptedIntentExecutor::new( + executor_ctx, + DEFAULT_ACTIONS_TIMEOUT, + )); + let (result, cleanup_handle) = + executor.execute(outbox_bundle.inner.clone()).await; + let ExecutionOutput::TwoStage { + commit_signature, + finalize_signature, + } = result.inner.expect("first execution succeeded") + else { + panic!("Expected TwoStage output"); + }; + cleanup_handle + .clean() + .await + .expect("cleanup after first run"); + + // Outbox should now show TwoStage::Finalizing + let outbox_bundle = test_env + .outbox_client() + .outbox_reader() + .fetch_outbox_intent(outbox_bundle.inner.id) + .await + .expect("fetch succeeded") + .expect("outbox bundle present"); + assert!( + matches!( + outbox_bundle.status(), + OutboxIntentBundleStatus::Executing(ExecutionStage::TwoStage( + TwoStageProgress::Finalizing { .. } + )) + ), + "Expected TwoStage::Finalizing in outbox, got {:?}", + outbox_bundle.status() + ); + + // Recovery: build_stage_intent_executor detects the finalize sig on chain and + // returns the same signatures without re-executing either stage. + let executor = build_stage_intent_executor( + test_env.executor_ctx_builder().build(), + outbox_bundle.status().clone(), + DEFAULT_ACTIONS_TIMEOUT, + ); + let (result, cleanup_handle) = Box::new(executor) + .execute(outbox_bundle.inner.clone()) + .await; + let ExecutionOutput::TwoStage { + commit_signature: retried_commit, + finalize_signature: retried_finalize, + } = result.inner.expect("recovery execution succeeded") + else { + panic!("Expected TwoStage output"); + }; + assert_eq!( + retried_commit, commit_signature, + "Commit sig must not change on recovery" + ); + assert_eq!( + retried_finalize, finalize_signature, + "Finalize sig must not change on recovery" + ); + cleanup_handle + .clean() + .await + .expect("cleanup after recovery"); + + // Counters are finalized and undelegated on chain + for (_, pda) in &counters { + let counter = test_env + .ctx + .fetch_chain_account_struct::(*pda) + .unwrap(); + assert!(counter.count > 0, "counter should be committed on chain"); + } +} + +#[derive(Clone, Default)] +struct RecordingCallbackScheduler { + calls: Arc>>, +} + +impl RecordingCallbackScheduler { + fn recorded_calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } +} + +impl ActionsCallbackScheduler for RecordingCallbackScheduler { + fn schedule( + &self, + callbacks: Vec, + signature: Option, + result: ActionResult, + ) -> Vec> { + let count = callbacks.len(); + self.calls + .lock() + .unwrap() + .push((callbacks, signature, result)); + (0..count).map(|_| Ok(Signature::new_unique())).collect() + } +} + +struct TestOutboxReader(Arc); + +#[async_trait] +impl OutboxIntentBundlesReader for TestOutboxReader { + type Error = anyhow::Error; + + async fn read( + &mut self, + _n: usize, + ) -> Result, Self::Error> { + Ok(vec![]) + } + + async fn fetch_outbox_intent( + &self, + intent_id: u64, + ) -> Result, Self::Error> { + let pda = outbox_intent_pda(intent_id); + let mut accounts = self.0.get_multiple_accounts(&[pda]).await?; + let Some(account) = accounts.pop().flatten() else { + return Ok(None); + }; + Ok(Some(OutboxIntentBundle::try_from_bytes(&account.data)?)) + } +} + +struct TestOutboxClient { + ephem_rpc: Arc, + pub stage_calls: Arc>>, + pub sent_commits: Arc>>, + + fail_set_execution_stage: bool, + set_execution_stage_sleep: Option, + fail_committing: bool, + fail_finalizing: bool, +} + +impl TestOutboxClient { + fn new(ephem_rpc: Arc) -> Self { + Self { + ephem_rpc, + stage_calls: Default::default(), + sent_commits: Default::default(), + fail_set_execution_stage: false, + set_execution_stage_sleep: None, + fail_committing: false, + fail_finalizing: false, + } + } + + fn with_fail_execution_stage(&mut self, value: bool) { + self.fail_set_execution_stage = value + } + + fn with_set_execution_stage_sleep(&mut self, value: Duration) { + self.set_execution_stage_sleep = Some(value); + } + + fn with_fail_finalizing(&mut self, value: bool) { + self.fail_finalizing = value; + } +} + +#[async_trait] +impl OutboxClient for TestOutboxClient { + type Error = InternalOutboxClientError; + type OutboxReader = TestOutboxReader; + + async fn accept_scheduled_intents( + &self, + ) -> Result< + Vec, + (Vec, Self::Error), + > { + Ok(vec![]) + } + + async fn set_intent_execution_stage( + &self, + intent_id: u64, + stage: ExecutionStage, + ) -> Result<(), Self::Error> { + if let Some(sleep_duration) = self.set_execution_stage_sleep { + tokio::time::sleep(sleep_duration).await; + }; + + let should_fail = self.fail_set_execution_stage + || matches!( + &stage, + ExecutionStage::TwoStage(TwoStageProgress::Committing(_)) + if self.fail_committing + ) + || matches!( + &stage, + ExecutionStage::TwoStage(TwoStageProgress::Finalizing { .. }) + if self.fail_finalizing + ); + + if should_fail { + return Err(Self::Error::RpcClientError( + client_error::ErrorKind::Custom( + "set_intent_execution_stage failed".to_string(), + ) + .into(), + )); + } + + self.stage_calls + .lock() + .unwrap() + .push((intent_id, stage.clone())); + let blockhash = self + .ephem_rpc + .get_latest_blockhash() + .await + .map_err(InternalOutboxClientError::RpcClientError)?; + let tx = InstructionUtils::set_intent_execution_stage( + blockhash, intent_id, stage, + ); + self.ephem_rpc + .send_and_confirm_transaction(&tx) + .await + .map_err(InternalOutboxClientError::RpcClientError)?; + Ok(()) + } + + async fn notify_commit_sent( + &self, + meta: ScheduledBaseIntentMeta, + result: &IntentExecutorResult, + _execution_report: &IntentExecutionReport, + ) -> Result<(), Self::Error> { + let IntentSentTransaction::Known(_) = meta.intent_sent_transaction + else { + panic!("should be known"); + }; + let succeeded = result.is_ok(); + self.sent_commits.lock().unwrap().push((meta.id, succeeded)); + Ok(()) + } + + fn outbox_reader(&self) -> Self::OutboxReader { + TestOutboxReader(self.ephem_rpc.clone()) + } +} + +struct SleepyRpcSender { + inner: HttpSender, + sleep_duration: Duration, +} + +#[async_trait] +impl RpcSender for SleepyRpcSender { + async fn send( + &self, + request: RpcRequest, + params: serde_json::Value, + ) -> solana_rpc_client_api::client_error::Result { + let result = self.inner.send(request, params).await; + tokio::time::sleep(self.sleep_duration).await; + result + } + + fn get_transport_stats(&self) -> RpcTransportStats { + self.inner.get_transport_stats() + } + + fn url(&self) -> String { + self.inner.url() + } +} + +fn ensure_validator_authority() -> Keypair { + static ONCE: Once = Once::new(); + let kp = Keypair::try_from(&DLP_TEST_AUTHORITY_BYTES[..]).unwrap(); + ONCE.call_once(|| { + init_validator_authority(kp.insecure_clone()); + }); + kp +} + +fn read_next_intent_id(ctx: &IntegrationTestContext) -> u64 { + let data = ctx.fetch_ephem_account_data(MAGIC_CONTEXT_PUBKEY).unwrap(); + MagicContext::intent_id(&data).unwrap() +} + +pub fn schedule_commit_instruction( + payer: &Pubkey, + pdas: Vec, +) -> Instruction { + let mut account_metas = vec![ + AccountMeta::new(*payer, true), + AccountMeta::new(MAGIC_CONTEXT_PUBKEY, false), + ]; + for pubkey in &pdas { + account_metas.push(AccountMeta::new_readonly(*pubkey, false)); + } + Instruction::new_with_bincode( + magicblock_magic_program_api::id(), + &MagicBlockInstruction::ScheduleCommit, + account_metas, + ) +} + +/// Schedules a CommitAndUndelegate bundle via ScheduleIntentBundle signed by the +/// validator authority. Using CommitAndUndelegate (vs CommitFinalize) generates +/// separate commit_tasks + finalize_tasks so build_execution_strategy selects +/// TwoStage execution. With 5 accounts the combined SingleStage tx needs ALTs +/// while each individual stage fits without them, guaranteeing TwoStage. +fn schedule_commit_and_undelegate_bundle_instruction( + payer: &Pubkey, + counter_pdas: &[Pubkey], +) -> Instruction { + // Indices are 1-based into the instruction account list: + // [0]=payer, [1]=magic_context, [2..]=counter PDAs + let indices: Vec = (2u8..2 + counter_pdas.len() as u8).collect(); + let args = MagicIntentBundleArgs { + commit: None, + commit_and_undelegate: Some(CommitAndUndelegateArgs { + commit_type: CommitTypeArgs::Standalone(indices), + undelegate_type: UndelegateTypeArgs::Standalone, + }), + commit_finalize: None, + commit_finalize_and_undelegate: None, + standalone_actions: vec![], + }; + let mut account_metas = vec![ + AccountMeta::new(*payer, true), + AccountMeta::new(MAGIC_CONTEXT_PUBKEY, false), + ]; + account_metas + .extend(counter_pdas.iter().map(|pk| AccountMeta::new(*pk, false))); + Instruction::new_with_bincode( + magicblock_magic_program_api::id(), + &MagicBlockInstruction::ScheduleIntentBundle(args), + account_metas, + ) +} + +fn schedule_commit_and_undelegate_bundle( + ctx: &IntegrationTestContext, + counter_pdas: &[Pubkey], +) { + let validator_keypair = ensure_validator_authority(); + let ix = schedule_commit_and_undelegate_bundle_instruction( + &validator_keypair.pubkey(), + counter_pdas, + ); + let mut tx = + Transaction::new_with_payer(&[ix], Some(&validator_keypair.pubkey())); + let sig = ctx + .send_transaction_ephem(&mut tx, &[&validator_keypair]) + .unwrap(); + println!("schedule_commit_and_undelegate_bundle sig: {}", sig); +} + +fn setup_counters( + ctx: &IntegrationTestContext, + n: usize, +) -> Vec<(Keypair, Pubkey)> { + (0..n) + .map(|i| { + let payer = setup_payer(ctx); + init_counter(ctx, &payer); + delegate_counter(ctx, &payer); + add_to_counter(ctx, &payer, (i + 1) as u8); + let pda = FlexiCounter::pda(&payer.pubkey()).0; + (payer, pda) + }) + .collect() +} diff --git a/test-integration/test-schedule-intent/tests/test_schedule_intents.rs b/test-integration/test-schedule-intent/tests/test_schedule_intents.rs index 2a16d1b90..bc7471e87 100644 --- a/test-integration/test-schedule-intent/tests/test_schedule_intents.rs +++ b/test-integration/test-schedule-intent/tests/test_schedule_intents.rs @@ -1,4 +1,5 @@ -use dlp_api::pda::ephemeral_balance_pda_from_payer; +mod common; +use common::*; use integration_test_tools::{ conversions::stringify_simulation_result, loaded_accounts::DLP_TEST_AUTHORITY_BYTES, IntegrationTestContext, @@ -6,7 +7,6 @@ use integration_test_tools::{ use program_flexi_counter::{ delegation_program_id, instruction::{ - create_add_ix, create_delegate_ix, create_init_ix, create_intent_bundle_commit_and_finalize_ix, create_intent_bundle_ix, create_intent_ix, create_transfer_intent_ix, }, @@ -14,14 +14,12 @@ use program_flexi_counter::{ }; use solana_rpc_client_api::config::RpcSimulateTransactionConfig; use solana_sdk::{ - native_token::LAMPORTS_PER_SOL, pubkey::Pubkey, rent::Rent, - signature::Keypair, signer::Signer, transaction::Transaction, + pubkey::Pubkey, signature::Keypair, signer::Signer, + transaction::Transaction, }; use test_kit::init_logger; use tracing::*; -const LABEL: &str = "I am a label"; - #[test] fn test_schedule_intent_basic() { // Init context @@ -519,9 +517,6 @@ fn test_transfer_intent_success() { // Cross-chain transfer amount // Also min rent for destination account as well const AMOUNT: u64 = 890_880; - // Fees charged from payer - const BASE_ACTION_FEE: u64 = 5000; - const CALLBACK_FEE: u64 = 5000; const CHARGED_AMOUNT: u64 = AMOUNT + BASE_ACTION_FEE + CALLBACK_FEE; init_logger!(); @@ -559,9 +554,6 @@ fn test_transfer_intent_failure_refunds_payer() { // Cross-chain transfer amount // Also min rent for destination account as well const AMOUNT: u64 = 890_880; - // Fees charged from payer - const BASE_ACTION_FEE: u64 = 5000; - const CALLBACK_FEE: u64 = 5000; init_logger!(); @@ -645,130 +637,6 @@ fn test_intent_bundle_commit_and_commit_finalize() { assert_eq!(owner_finalize, delegation_program_id()); } -fn setup_payer(ctx: &IntegrationTestContext) -> Keypair { - // Airdrop to payer on chain - let payer = Keypair::new(); - ctx.airdrop_chain(&payer.pubkey(), LAMPORTS_PER_SOL) - .unwrap(); - - // Create actor escrow - let ix = dlp_api::instruction_builder::top_up_ephemeral_balance( - payer.pubkey(), - payer.pubkey(), - Some(LAMPORTS_PER_SOL / 2), - Some(1), - ); - ctx.send_and_confirm_instructions_with_payer_chain(&[ix], &payer) - .unwrap(); - - // Confirm actor escrow - let escrow_pda = ephemeral_balance_pda_from_payer(&payer.pubkey(), 1); - let rent = Rent::default().minimum_balance(0); - assert_eq!( - ctx.fetch_chain_account(escrow_pda).unwrap().lamports, - LAMPORTS_PER_SOL / 2 + rent - ); - - payer -} - -fn init_counter(ctx: &IntegrationTestContext, payer: &Keypair) { - let ix = create_init_ix(payer.pubkey(), LABEL.to_string()); - let (_, confirmed) = ctx - .send_and_confirm_instructions_with_payer_chain(&[ix], payer) - .unwrap(); - assert!(confirmed, "Should confirm transaction"); - - let counter_pda = FlexiCounter::pda(&payer.pubkey()).0; - let counter = ctx - .fetch_chain_account_struct::(counter_pda) - .unwrap(); - assert_eq!( - counter, - FlexiCounter { - count: 0, - updates: 0, - label: LABEL.to_string() - }, - ) -} - -// ER action -fn delegate_counter(ctx: &IntegrationTestContext, payer: &Keypair) { - ctx.wait_for_next_slot_ephem().unwrap(); - - let counter_pda = FlexiCounter::pda(&payer.pubkey()).0; - let ix = create_delegate_ix(payer.pubkey()); - ctx.send_and_confirm_instructions_with_payer_chain(&[ix], payer) - .unwrap(); - - // Confirm delegated - let owner = ctx.fetch_chain_account_owner(counter_pda).unwrap(); - assert_eq!(owner, delegation_program_id()); -} - -// ER action -fn add_to_counter(ctx: &IntegrationTestContext, payer: &Keypair, value: u8) { - ctx.wait_for_next_slot_ephem().unwrap(); - - let counter_pda = FlexiCounter::pda(&payer.pubkey()).0; - let counter_before = ctx - .fetch_ephem_account_struct::(counter_pda) - .unwrap_or(FlexiCounter { - count: 0, - updates: 0, - label: LABEL.to_string(), - }); - - // Add value to counter - let ix = create_add_ix(payer.pubkey(), value); - ctx.send_and_confirm_instructions_with_payer_ephem(&[ix], payer) - .unwrap(); - - let counter = ctx - .fetch_ephem_account_struct::(counter_pda) - .unwrap(); - assert_eq!( - counter, - FlexiCounter { - count: counter_before.count + value as u64, - updates: counter_before.updates + 1, - label: LABEL.to_string() - }, - ) -} - -struct ExpectedCounter { - pda: Pubkey, - expected: u64, -} - -fn assert_counters( - ctx: &IntegrationTestContext, - expected_counters: &[ExpectedCounter], - is_base: bool, -) { - // Confirm results on base lauer - let actual_counter = expected_counters - .iter() - .map(|counter| { - if is_base { - ctx.fetch_chain_account_struct::(counter.pda) - .unwrap() - } else { - ctx.fetch_ephem_account_struct::(counter.pda) - .unwrap() - } - }) - .collect::>(); - - for i in 0..actual_counter.len() { - let actual_counter = &actual_counter[i]; - let expected_counter = &expected_counters[i]; - assert_eq!(actual_counter.count, expected_counter.expected); - } -} - fn schedule_intent( ctx: &IntegrationTestContext, payers: &[&Keypair], @@ -989,37 +857,3 @@ fn dump_ephem_simulation( } } } - -fn verify_undelegation_in_ephem_via_owner( - pubkeys: &[Pubkey], - ctx: &IntegrationTestContext, -) { - const RETRY_LIMIT: usize = 20; - let mut retries = 0; - - loop { - ctx.wait_for_next_slot_ephem().unwrap(); - let mut not_verified = vec![]; - for pk in pubkeys.iter() { - let counter_pda = FlexiCounter::pda(pk).0; - let owner = ctx.fetch_ephem_account_owner(counter_pda).unwrap(); - if owner == delegation_program_id() { - not_verified.push(*pk); - } - } - if not_verified.is_empty() { - break; - } - retries += 1; - if retries >= RETRY_LIMIT { - panic!( - "Failed to verify undelegation for pubkeys: {}", - not_verified - .iter() - .map(|k| k.to_string()) - .collect::>() - .join(", ") - ); - } - } -} diff --git a/test-integration/test-tools/src/integration_test_context.rs b/test-integration/test-tools/src/integration_test_context.rs index bd76fe927..97909f834 100644 --- a/test-integration/test-tools/src/integration_test_context.rs +++ b/test-integration/test-tools/src/integration_test_context.rs @@ -287,6 +287,15 @@ impl IntegrationTestContext { Ok(ephem_client) } + pub fn try_ephem_client_async( + &self, + ) -> anyhow::Result { + let Some(ephem_client) = self.ephem_client.as_ref() else { + return Err(anyhow::anyhow!("Ephem client not available")); + }; + Ok(async_rpc_client(ephem_client)) + } + pub fn fetch_ephem_account_data( &self, pubkey: Pubkey,