From 81911dfdbf1fc2327a27577776c872f387e13866 Mon Sep 17 00:00:00 2001 From: Dodecahedr0x Date: Thu, 4 Dec 2025 12:47:25 +0100 Subject: [PATCH 01/11] feat: replace task executor with rpc client --- Cargo.lock | 5 +- magicblock-api/src/magic_validator.rs | 2 +- magicblock-task-scheduler/Cargo.toml | 7 +- magicblock-task-scheduler/src/db.rs | 32 ++- magicblock-task-scheduler/src/errors.rs | 34 +--- magicblock-task-scheduler/src/service.rs | 26 +-- magicblock-task-scheduler/tests/service.rs | 219 --------------------- test-integration/Cargo.lock | 1 + 8 files changed, 32 insertions(+), 294 deletions(-) delete mode 100644 magicblock-task-scheduler/tests/service.rs diff --git a/Cargo.lock b/Cargo.lock index 5285f84d8..e6cd11f9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3137,25 +3137,22 @@ dependencies = [ "bincode", "chrono", "futures-util", - "guinea", "log", "magicblock-config", "magicblock-core", "magicblock-ledger", - "magicblock-magic-program-api", "magicblock-program", "rusqlite", - "solana-account", "solana-instruction", "solana-message", "solana-program", "solana-pubkey", "solana-pubsub-client", + "solana-rpc-client", "solana-rpc-client-api", "solana-signature", "solana-transaction", "solana-transaction-error", - "test-kit", "thiserror 1.0.69", "tokio", "tokio-util", diff --git a/magicblock-api/src/magic_validator.rs b/magicblock-api/src/magic_validator.rs index e060b91dd..6d9d48105 100644 --- a/magicblock-api/src/magic_validator.rs +++ b/magicblock-api/src/magic_validator.rs @@ -295,7 +295,7 @@ impl MagicValidator { let task_scheduler = TaskSchedulerService::new( &task_scheduler_db_path, &config.task_scheduler, - dispatch.transaction_scheduler.clone(), + RpcClient::new(format!("http://{}", config.listen)), dispatch .tasks_service .take() diff --git a/magicblock-task-scheduler/Cargo.toml b/magicblock-task-scheduler/Cargo.toml index d70eae05b..51668ef2a 100644 --- a/magicblock-task-scheduler/Cargo.toml +++ b/magicblock-task-scheduler/Cargo.toml @@ -22,6 +22,7 @@ solana-message = { workspace = true } solana-program = { workspace = true } solana-pubkey = { workspace = true } solana-pubsub-client = { workspace = true } +solana-rpc-client = { workspace = true } solana-rpc-client-api = { workspace = true } solana-signature = { workspace = true } solana-transaction = { workspace = true } @@ -29,9 +30,3 @@ solana-transaction-error = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true, features = ["time"] } - -[dev-dependencies] -magicblock-magic-program-api = { workspace = true } -test-kit = { workspace = true } -guinea = { workspace = true } -solana-account = { workspace = true } diff --git a/magicblock-task-scheduler/src/db.rs b/magicblock-task-scheduler/src/db.rs index aeb328615..5ec23d6a9 100644 --- a/magicblock-task-scheduler/src/db.rs +++ b/magicblock-task-scheduler/src/db.rs @@ -7,7 +7,7 @@ use solana_instruction::Instruction; use solana_pubkey::Pubkey; use tokio::sync::Mutex; -use crate::errors::TaskSchedulerError; +use crate::errors::TaskSchedulerResult; /// Represents a task in the database /// Uses i64 for all timestamps and IDs to avoid overflows @@ -65,7 +65,7 @@ impl SchedulerDatabase { path.join("task_scheduler.sqlite") } - pub fn new>(path: P) -> Result { + pub fn new>(path: P) -> TaskSchedulerResult { let conn = Connection::open(path)?; // Create tables @@ -108,10 +108,7 @@ impl SchedulerDatabase { }) } - pub async fn insert_task( - &self, - task: &DbTask, - ) -> Result<(), TaskSchedulerError> { + pub async fn insert_task(&self, task: &DbTask) -> TaskSchedulerResult<()> { let instructions_bin = bincode::serialize(&task.instructions)?; let authority_str = task.authority.to_string(); let now = Utc::now().timestamp_millis(); @@ -139,7 +136,7 @@ impl SchedulerDatabase { &self, task_id: i64, last_execution: i64, - ) -> Result<(), TaskSchedulerError> { + ) -> TaskSchedulerResult<()> { let now = Utc::now().timestamp_millis(); self.conn.lock().await.execute( @@ -158,7 +155,7 @@ impl SchedulerDatabase { &self, task_id: i64, error: String, - ) -> Result<(), TaskSchedulerError> { + ) -> TaskSchedulerResult<()> { self.conn.lock().await.execute( "INSERT INTO failed_scheduling (timestamp, task_id, error) VALUES (?, ?, ?)", params![Utc::now().timestamp_millis(), task_id, error], @@ -171,7 +168,7 @@ impl SchedulerDatabase { &self, task_id: i64, error: String, - ) -> Result<(), TaskSchedulerError> { + ) -> TaskSchedulerResult<()> { self.conn.lock().await.execute( "INSERT INTO failed_tasks (timestamp, task_id, error) VALUES (?, ?, ?)", params![Utc::now().timestamp_millis(), task_id, error], @@ -183,7 +180,7 @@ impl SchedulerDatabase { pub async fn unschedule_task( &self, task_id: i64, - ) -> Result<(), TaskSchedulerError> { + ) -> TaskSchedulerResult<()> { self.conn.lock().await.execute( "UPDATE tasks SET executions_left = 0 WHERE id = ?", [task_id], @@ -192,10 +189,7 @@ impl SchedulerDatabase { Ok(()) } - pub async fn remove_task( - &self, - task_id: i64, - ) -> Result<(), TaskSchedulerError> { + pub async fn remove_task(&self, task_id: i64) -> TaskSchedulerResult<()> { self.conn .lock() .await @@ -207,7 +201,7 @@ impl SchedulerDatabase { pub async fn get_task( &self, task_id: i64, - ) -> Result, TaskSchedulerError> { + ) -> TaskSchedulerResult> { let db = self.conn.lock().await; let mut stmt = db.prepare( "SELECT id, instructions, authority, execution_interval_millis, executions_left, last_execution_millis @@ -244,7 +238,7 @@ impl SchedulerDatabase { Ok(rows.next().transpose()?) } - pub async fn get_tasks(&self) -> Result, TaskSchedulerError> { + pub async fn get_tasks(&self) -> TaskSchedulerResult> { let db = self.conn.lock().await; let mut stmt = db.prepare( "SELECT id, instructions, authority, execution_interval_millis, executions_left, last_execution_millis @@ -286,7 +280,7 @@ impl SchedulerDatabase { Ok(tasks) } - pub async fn get_task_ids(&self) -> Result, TaskSchedulerError> { + pub async fn get_task_ids(&self) -> TaskSchedulerResult> { let db = self.conn.lock().await; let mut stmt = db.prepare( "SELECT id @@ -300,7 +294,7 @@ impl SchedulerDatabase { pub async fn get_failed_schedulings( &self, - ) -> Result, TaskSchedulerError> { + ) -> TaskSchedulerResult> { let db = self.conn.lock().await; let mut stmt = db.prepare( "SELECT * @@ -321,7 +315,7 @@ impl SchedulerDatabase { pub async fn get_failed_tasks( &self, - ) -> Result, TaskSchedulerError> { + ) -> TaskSchedulerResult> { let db = self.conn.lock().await; let mut stmt = db.prepare( "SELECT * diff --git a/magicblock-task-scheduler/src/errors.rs b/magicblock-task-scheduler/src/errors.rs index d7ab79a50..7e070699e 100644 --- a/magicblock-task-scheduler/src/errors.rs +++ b/magicblock-task-scheduler/src/errors.rs @@ -7,47 +7,15 @@ pub enum TaskSchedulerError { #[error(transparent)] DatabaseConnection(#[from] rusqlite::Error), - #[error(transparent)] - Pubsub( - Box< - solana_pubsub_client::nonblocking::pubsub_client::PubsubClientError, - >, - ), - #[error(transparent)] Bincode(#[from] bincode::Error), - #[error("Task not found: {0}")] - TaskNotFound(i64), - #[error(transparent)] - Transaction(#[from] solana_transaction_error::TransactionError), - - #[error("Task context not found")] - TaskContextNotFound, + Rpc(#[from] Box), #[error(transparent)] Io(#[from] std::io::Error), - #[error("Failed to process some context requests: {0:?}")] - SchedulingRequests(Vec), - - #[error("Failed to serialize task context: {0:?}")] - ContextSerialization(Vec), - - #[error("Failed to deserialize task context: {0:?}")] - ContextDeserialization(Vec), - #[error("Task {0} already exists and is owned by {1}, not {2}")] UnauthorizedReplacing(i64, String, String), } - -impl From - for TaskSchedulerError -{ - fn from( - e: solana_pubsub_client::nonblocking::pubsub_client::PubsubClientError, - ) -> Self { - Self::Pubsub(Box::new(e)) - } -} diff --git a/magicblock-task-scheduler/src/service.rs b/magicblock-task-scheduler/src/service.rs index f262b95b1..285e15f89 100644 --- a/magicblock-task-scheduler/src/service.rs +++ b/magicblock-task-scheduler/src/service.rs @@ -7,9 +7,7 @@ use std::{ use futures_util::StreamExt; use log::*; use magicblock_config::config::TaskSchedulerConfig; -use magicblock_core::link::transactions::{ - ScheduledTasksRx, TransactionSchedulerHandle, -}; +use magicblock_core::link::transactions::ScheduledTasksRx; use magicblock_ledger::LatestBlock; use magicblock_program::{ args::{CancelTaskRequest, TaskRequest}, @@ -18,6 +16,7 @@ use magicblock_program::{ }; use solana_instruction::Instruction; use solana_message::Message; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; use solana_signature::Signature; use solana_transaction::Transaction; use tokio::{select, task::JoinHandle, time::Duration}; @@ -34,8 +33,8 @@ use crate::{ pub struct TaskSchedulerService { /// Database for persisting tasks db: SchedulerDatabase, - /// Used to send transactions for execution - tx_scheduler: TransactionSchedulerHandle, + /// RPC client used to send transactions + rpc_client: RpcClient, /// Used to receive scheduled tasks from the transaction executor scheduled_tasks: ScheduledTasksRx, /// Provides latest blockhash for signing transactions @@ -52,7 +51,7 @@ pub struct TaskSchedulerService { enum ProcessingOutcome { Success, - Recoverable(TaskSchedulerError), + Recoverable(Box), } // SAFETY: TaskSchedulerService is moved into a single Tokio task in `start()` and never cloned. @@ -65,11 +64,11 @@ impl TaskSchedulerService { pub fn new( path: &Path, config: &TaskSchedulerConfig, - tx_scheduler: TransactionSchedulerHandle, + rpc_client: RpcClient, scheduled_tasks: ScheduledTasksRx, block: LatestBlock, token: CancellationToken, - ) -> Result { + ) -> TaskSchedulerResult { if config.reset { match std::fs::remove_file(path) { Ok(_) => {} @@ -87,7 +86,7 @@ impl TaskSchedulerService { let db = SchedulerDatabase::new(path)?; Ok(Self { db, - tx_scheduler, + rpc_client, scheduled_tasks, block, task_queue: DelayQueue::new(), @@ -139,7 +138,7 @@ impl TaskSchedulerService { schedule_request.id, e ); - return Ok(ProcessingOutcome::Recoverable(e)); + return Ok(ProcessingOutcome::Recoverable(Box::new(e))); } } TaskRequest::Cancel(cancel_request) => { @@ -157,7 +156,7 @@ impl TaskSchedulerService { cancel_request.task_id, e ); - return Ok(ProcessingOutcome::Recoverable(e)); + return Ok(ProcessingOutcome::Recoverable(Box::new(e))); } } }; @@ -323,7 +322,10 @@ impl TaskSchedulerService { ); let sig = tx.signatures[0]; - self.tx_scheduler.execute(tx).await?; + self.rpc_client + .send_transaction(&tx) + .await + .map_err(Box::new)?; Ok(sig) } } diff --git a/magicblock-task-scheduler/tests/service.rs b/magicblock-task-scheduler/tests/service.rs deleted file mode 100644 index 7abe9f3fc..000000000 --- a/magicblock-task-scheduler/tests/service.rs +++ /dev/null @@ -1,219 +0,0 @@ -use std::time::Duration; - -use guinea::GuineaInstruction; -use magicblock_config::config::TaskSchedulerConfig; -use magicblock_program::{ - args::ScheduleTaskArgs, - validator::{init_validator_authority_if_needed, validator_authority_id}, -}; -use magicblock_task_scheduler::{ - errors::TaskSchedulerResult, SchedulerDatabase, TaskSchedulerError, - TaskSchedulerService, -}; -use solana_account::ReadableAccount; -use solana_program::{ - instruction::{AccountMeta, Instruction}, - native_token::LAMPORTS_PER_SOL, -}; -use test_kit::{ExecutionTestEnv, Signer}; -use tokio::task::JoinHandle; -use tokio_util::sync::CancellationToken; - -type SetupResult = TaskSchedulerResult<( - ExecutionTestEnv, - CancellationToken, - JoinHandle>, -)>; - -async fn setup() -> SetupResult { - let mut env = ExecutionTestEnv::new(); - - init_validator_authority_if_needed(env.payers[0].insecure_clone()); - // NOTE: validator authority is unique for all tests in this file, but the payer changes for each test - // Airdrop some SOL to the validator authority, which is used to pay task fees - env.fund_account(validator_authority_id(), LAMPORTS_PER_SOL); - - let token = CancellationToken::new(); - let task_scheduler_db_path = SchedulerDatabase::path( - env.ledger - .ledger_path() - .parent() - .expect("ledger_path didn't have a parent, should never happen"), - ); - let handle = TaskSchedulerService::new( - &task_scheduler_db_path, - &TaskSchedulerConfig::default(), - env.transaction_scheduler.clone(), - env.dispatch - .tasks_service - .take() - .expect("Tasks service should be initialized"), - env.ledger.latest_block().clone(), - token.clone(), - )? - .start() - .await?; - - Ok((env, token, handle)) -} - -#[tokio::test] -pub async fn test_schedule_task() -> TaskSchedulerResult<()> { - let (env, token, handle) = setup().await?; - - let account = - env.create_account_with_config(LAMPORTS_PER_SOL, 1, guinea::ID); - - // Schedule a task - let ix = Instruction::new_with_bincode( - guinea::ID, - &GuineaInstruction::ScheduleTask(ScheduleTaskArgs { - task_id: 1, - execution_interval_millis: 10, - iterations: 1, - instructions: vec![Instruction::new_with_bincode( - guinea::ID, - &GuineaInstruction::Increment, - vec![AccountMeta::new(account.pubkey(), false)], - )], - }), - vec![ - AccountMeta::new_readonly(magicblock_magic_program_api::ID, false), - AccountMeta::new(env.get_payer().pubkey, true), - AccountMeta::new(account.pubkey(), false), - ], - ); - let txn = env.build_transaction(&[ix]); - let result = env.execute_transaction(txn).await; - assert!( - result.is_ok(), - "failed to execute schedule task transaction: {:?}", - result - ); - - // Wait until the task scheduler actually mutates the account (with an upper bound to avoid hangs) - tokio::time::timeout(Duration::from_secs(1), async { - loop { - if env.get_account(account.pubkey()).data().first() == Some(&1) { - break; - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - }) - .await - .expect("task scheduler never incremented the account within 1s"); - - token.cancel(); - handle.await.expect("task service join handle failed")?; - - Ok(()) -} - -#[tokio::test] -pub async fn test_cancel_task() -> TaskSchedulerResult<()> { - let (env, token, handle) = setup().await?; - - let account = - env.create_account_with_config(LAMPORTS_PER_SOL, 1, guinea::ID); - - // Schedule a task - let task_id = 2; - let interval = 100; - let ix = Instruction::new_with_bincode( - guinea::ID, - &GuineaInstruction::ScheduleTask(ScheduleTaskArgs { - task_id, - execution_interval_millis: interval, - iterations: 100, - instructions: vec![Instruction::new_with_bincode( - guinea::ID, - &GuineaInstruction::Increment, - vec![AccountMeta::new(account.pubkey(), false)], - )], - }), - vec![ - AccountMeta::new_readonly(magicblock_magic_program_api::ID, false), - AccountMeta::new(env.get_payer().pubkey, true), - AccountMeta::new(account.pubkey(), false), - ], - ); - let txn = env.build_transaction(&[ix]); - let result = env.execute_transaction(txn).await; - assert!( - result.is_ok(), - "failed to execute schedule task transaction: {:?}", - result - ); - - // Wait until we actually observe at least five executions - let executed_before_cancel = tokio::time::timeout( - Duration::from_millis(10 * interval as u64), - async { - loop { - if let Some(value) = - env.get_account(account.pubkey()).data().first() - { - if *value >= 5 { - break *value; - } - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - }, - ) - .await - .expect("task scheduler never reached five executions within 10 intervals"); - - // Cancel the task - let ix = Instruction::new_with_bincode( - guinea::ID, - &GuineaInstruction::CancelTask(task_id), - vec![ - AccountMeta::new_readonly(magicblock_magic_program_api::ID, false), - AccountMeta::new(env.get_payer().pubkey, true), - ], - ); - let txn = env.build_transaction(&[ix]); - let result = env.execute_transaction(txn).await; - assert!( - result.is_ok(), - "failed to execute cancel task transaction: {:?}", - result - ); - - // Wait for the cancel to be processed - tokio::time::sleep(Duration::from_millis(interval as u64)).await; - - let value_at_cancel = env - .get_account(account.pubkey()) - .data() - .first() - .copied() - .unwrap_or_default(); - assert!( - value_at_cancel >= executed_before_cancel, - "unexpected: value at cancellation ({}) < value when 5 executions were observed ({})", - value_at_cancel, - executed_before_cancel - ); - - // Ensure the scheduler stops issuing executions after cancellation - tokio::time::sleep(Duration::from_millis(2 * interval as u64)).await; - - let value_after_cancel = env - .get_account(account.pubkey()) - .data() - .first() - .copied() - .unwrap_or_default(); - - assert_eq!( - value_after_cancel, value_at_cancel, - "task scheduler kept executing after cancellation" - ); - - token.cancel(); - handle.await.expect("task service join handle failed")?; - - Ok(()) -} diff --git a/test-integration/Cargo.lock b/test-integration/Cargo.lock index f2f0eea2a..129fcaacc 100644 --- a/test-integration/Cargo.lock +++ b/test-integration/Cargo.lock @@ -3539,6 +3539,7 @@ dependencies = [ "solana-program", "solana-pubkey", "solana-pubsub-client", + "solana-rpc-client", "solana-rpc-client-api", "solana-signature", "solana-transaction", From 649b10aedea2e645f1fe5947ad488bf0545b9515 Mon Sep 17 00:00:00 2001 From: Dodecahedr0x Date: Thu, 4 Dec 2025 12:58:30 +0100 Subject: [PATCH 02/11] feat: helper for url --- magicblock-api/src/magic_validator.rs | 2 +- magicblock-config/src/types/network.rs | 82 +++++++++++++++++++++++--- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/magicblock-api/src/magic_validator.rs b/magicblock-api/src/magic_validator.rs index 6d9d48105..517b2cdc7 100644 --- a/magicblock-api/src/magic_validator.rs +++ b/magicblock-api/src/magic_validator.rs @@ -295,7 +295,7 @@ impl MagicValidator { let task_scheduler = TaskSchedulerService::new( &task_scheduler_db_path, &config.task_scheduler, - RpcClient::new(format!("http://{}", config.listen)), + RpcClient::new(config.listen.http()), dispatch .tasks_service .take() diff --git a/magicblock-config/src/types/network.rs b/magicblock-config/src/types/network.rs index 0194e5482..871d2e44b 100644 --- a/magicblock-config/src/types/network.rs +++ b/magicblock-config/src/types/network.rs @@ -20,13 +20,81 @@ impl Default for BindAddress { } } -/// A remote endpoint for syncing with the base chain. -/// -/// Supported types: -/// - **Http**: JSON-RPC HTTP endpoint (scheme: `http` or `https`) -/// - **Websocket**: WebSocket endpoint for PubSub subscriptions (scheme: `ws` or `wss`) -/// - **Grpc**: gRPC endpoint for streaming (schemes `grpc`/`grpcs` are converted to `http`/`https`) -#[derive(Clone, DeserializeFromStr, SerializeDisplay, Display, Debug)] +impl BindAddress { + pub fn http(&self) -> String { + format!("http://{}", self.0) + } + + pub fn websocket(&self) -> String { + format!("ws://{}", self.0) + } +} + +/// A connection to one or more remote clusters (e.g., "devnet"). +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "kebab-case", untagged)] +pub enum RemoteCluster { + Single(Remote), + Multiple(Vec), +} + +impl RemoteCluster { + pub fn http(&self) -> &Url { + let remote = match self { + Self::Single(r) => r, + Self::Multiple(rs) => { + rs.first().expect("non-empty remote cluster array") + } + }; + match remote { + Remote::Unified(url) => &url.0, + Remote::Disjointed { http, .. } => &http.0, + } + } + + pub fn websocket(&self) -> Box + '_> { + fn ws(remote: &Remote) -> Url { + match remote { + Remote::Unified(url) => { + let mut url = Url::clone(&url.0); + let scheme = + if url.scheme() == "https" { "wss" } else { "ws" }; + let _ = url.set_scheme(scheme); + if let Some(port) = url.port() { + // By solana convention, websocket listens on rpc port + 1 + let _ = url.set_port(Some(port + 1)); + } + url + } + Remote::Disjointed { ws, .. } => ws.0.clone(), + } + } + match self { + Self::Single(r) => Box::new(iter::once(ws(r))), + Self::Multiple(rs) => Box::new(rs.iter().map(ws)), + } + } +} + +impl FromStr for RemoteCluster { + type Err = url::ParseError; + fn from_str(s: &str) -> Result { + AliasedUrl::from_str(s).map(|url| Self::Single(Remote::Unified(url))) + } +} + +impl Default for RemoteCluster { + fn default() -> Self { + consts::DEFAULT_REMOTE + .parse() + .expect("Default remote should be valid") + } +} + +/// A connection to a single remote node. +#[serde_as] +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "kebab-case", untagged)] pub enum Remote { Http(AliasedUrl), Websocket(AliasedUrl), From 1697b32205b0cd432d45c5ad861125fa1051b7e8 Mon Sep 17 00:00:00 2001 From: Dodecahedr0x Date: Thu, 4 Dec 2025 14:18:05 +0100 Subject: [PATCH 03/11] fix: disable cranked commits in test --- test-integration/programs/schedulecommit/src/api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-integration/programs/schedulecommit/src/api.rs b/test-integration/programs/schedulecommit/src/api.rs index adecc662b..06ef7fca4 100644 --- a/test-integration/programs/schedulecommit/src/api.rs +++ b/test-integration/programs/schedulecommit/src/api.rs @@ -65,7 +65,7 @@ pub fn delegate_account_cpi_instruction( let args = DelegateCpiArgs { valid_until: i64::MAX, - commit_frequency_ms: 1_000_000_000, + commit_frequency_ms: u32::MAX, validator, player, }; From aebce92503c471f0dd781fcf9cdaa989321d64bb Mon Sep 17 00:00:00 2001 From: Dodecahedr0x Date: Thu, 4 Dec 2025 14:55:05 +0100 Subject: [PATCH 04/11] fix: set 0 commit frequency --- test-integration/programs/schedulecommit/src/api.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-integration/programs/schedulecommit/src/api.rs b/test-integration/programs/schedulecommit/src/api.rs index 06ef7fca4..8e1d84543 100644 --- a/test-integration/programs/schedulecommit/src/api.rs +++ b/test-integration/programs/schedulecommit/src/api.rs @@ -65,7 +65,7 @@ pub fn delegate_account_cpi_instruction( let args = DelegateCpiArgs { valid_until: i64::MAX, - commit_frequency_ms: u32::MAX, + commit_frequency_ms: 0, validator, player, }; From 7bb114a41d05f66cf48a10eb28233640f6837abd Mon Sep 17 00:00:00 2001 From: Dodecahedr0x Date: Thu, 4 Dec 2025 14:56:13 +0100 Subject: [PATCH 05/11] feat: better bind address parsing --- magicblock-config/src/types/network.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/magicblock-config/src/types/network.rs b/magicblock-config/src/types/network.rs index 871d2e44b..cf3a78876 100644 --- a/magicblock-config/src/types/network.rs +++ b/magicblock-config/src/types/network.rs @@ -21,12 +21,26 @@ impl Default for BindAddress { } impl BindAddress { + fn as_connect_addr(&self) -> SocketAddr { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + match self.0.ip() { + IpAddr::V4(ip) if ip.is_unspecified() => { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), self.0.port()) + } + IpAddr::V6(ip) if ip.is_unspecified() => { + SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), self.0.port()) + } + _ => self.0, + } + } + pub fn http(&self) -> String { - format!("http://{}", self.0) + format!("http://{}", self.as_connect_addr()) } pub fn websocket(&self) -> String { - format!("ws://{}", self.0) + format!("ws://{}", self.as_connect_addr()) } } From 46b4904945d1a3547cfbd249189560c78855c69a Mon Sep 17 00:00:00 2001 From: Dodecahedr0x Date: Fri, 5 Dec 2025 14:26:37 +0100 Subject: [PATCH 06/11] fix: dependencies --- Cargo.lock | 2 -- magicblock-task-scheduler/Cargo.toml | 2 -- test-integration/Cargo.lock | 2 -- 3 files changed, 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6cd11f9e..e8bd5f177 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3145,9 +3145,7 @@ dependencies = [ "rusqlite", "solana-instruction", "solana-message", - "solana-program", "solana-pubkey", - "solana-pubsub-client", "solana-rpc-client", "solana-rpc-client-api", "solana-signature", diff --git a/magicblock-task-scheduler/Cargo.toml b/magicblock-task-scheduler/Cargo.toml index 51668ef2a..dd1f6a8bb 100644 --- a/magicblock-task-scheduler/Cargo.toml +++ b/magicblock-task-scheduler/Cargo.toml @@ -19,9 +19,7 @@ magicblock-program = { workspace = true } rusqlite = { workspace = true } solana-instruction = { workspace = true } solana-message = { workspace = true } -solana-program = { workspace = true } solana-pubkey = { workspace = true } -solana-pubsub-client = { workspace = true } solana-rpc-client = { workspace = true } solana-rpc-client-api = { workspace = true } solana-signature = { workspace = true } diff --git a/test-integration/Cargo.lock b/test-integration/Cargo.lock index 129fcaacc..116acb551 100644 --- a/test-integration/Cargo.lock +++ b/test-integration/Cargo.lock @@ -3536,9 +3536,7 @@ dependencies = [ "rusqlite", "solana-instruction", "solana-message", - "solana-program", "solana-pubkey", - "solana-pubsub-client", "solana-rpc-client", "solana-rpc-client-api", "solana-signature", From 833bda67b5267db6f4fd6129f8f1d403c2baf5cd Mon Sep 17 00:00:00 2001 From: Dodecahedr0x Date: Mon, 8 Dec 2025 09:52:13 +0100 Subject: [PATCH 07/11] feat: hide internal detail --- magicblock-api/src/magic_validator.rs | 2 +- magicblock-task-scheduler/src/service.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/magicblock-api/src/magic_validator.rs b/magicblock-api/src/magic_validator.rs index 517b2cdc7..5b1015c81 100644 --- a/magicblock-api/src/magic_validator.rs +++ b/magicblock-api/src/magic_validator.rs @@ -295,7 +295,7 @@ impl MagicValidator { let task_scheduler = TaskSchedulerService::new( &task_scheduler_db_path, &config.task_scheduler, - RpcClient::new(config.listen.http()), + config.listen.http(), dispatch .tasks_service .take() diff --git a/magicblock-task-scheduler/src/service.rs b/magicblock-task-scheduler/src/service.rs index 285e15f89..0cc635c0c 100644 --- a/magicblock-task-scheduler/src/service.rs +++ b/magicblock-task-scheduler/src/service.rs @@ -64,7 +64,7 @@ impl TaskSchedulerService { pub fn new( path: &Path, config: &TaskSchedulerConfig, - rpc_client: RpcClient, + rpc_url: String, scheduled_tasks: ScheduledTasksRx, block: LatestBlock, token: CancellationToken, @@ -86,7 +86,7 @@ impl TaskSchedulerService { let db = SchedulerDatabase::new(path)?; Ok(Self { db, - rpc_client, + rpc_client: RpcClient::new(rpc_url), scheduled_tasks, block, task_queue: DelayQueue::new(), From 49f27b2c0a149594283280a534d8e62d1874d7a2 Mon Sep 17 00:00:00 2001 From: Dodecahedr0x Date: Mon, 8 Dec 2025 09:55:16 +0100 Subject: [PATCH 08/11] feat: simplify returned signature --- magicblock-task-scheduler/src/service.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/magicblock-task-scheduler/src/service.rs b/magicblock-task-scheduler/src/service.rs index 0cc635c0c..8f9419ac0 100644 --- a/magicblock-task-scheduler/src/service.rs +++ b/magicblock-task-scheduler/src/service.rs @@ -321,11 +321,10 @@ impl TaskSchedulerService { blockhash, ); - let sig = tx.signatures[0]; - self.rpc_client + Ok(self + .rpc_client .send_transaction(&tx) .await - .map_err(Box::new)?; - Ok(sig) + .map_err(Box::new)?) } } From 035f81208374ed1dbc6a03ecf7c4add7a7010466 Mon Sep 17 00:00:00 2001 From: Babur Makhmudov <31780624+bmuddha@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:37:32 +0400 Subject: [PATCH 09/11] Feat: multi threaded scheduler (#589) Co-authored-by: Thorsten Lorenz Co-authored-by: Dodecahedr0x Co-authored-by: taco-paco --- Cargo.lock | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e8bd5f177..248f1ad5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -480,24 +480,6 @@ dependencies = [ "syn 2.0.104", ] -[[package]] -name = "bindgen" -version = "0.71.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" -dependencies = [ - "bitflags 2.9.1", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.1", - "shlex", - "syn 2.0.104", -] - [[package]] name = "bitflags" version = "1.3.2" From 77347eee2bb357ddf6d9cee22e93ea22ae691473 Mon Sep 17 00:00:00 2001 From: Thorsten Lorenz Date: Tue, 16 Dec 2025 09:44:14 -0700 Subject: [PATCH 10/11] feat: kind-based multi-remote configuration (#746) --- Cargo.lock | 18 + config.example.toml | 58 ++- magicblock-api/src/magic_validator.rs | 34 +- magicblock-config/src/config/cli.rs | 27 +- magicblock-config/src/consts.rs | 12 +- magicblock-config/src/lib.rs | 47 +- magicblock-config/src/tests.rs | 252 +++++++-- magicblock-config/src/types/mod.rs | 4 +- magicblock-config/src/types/network.rs | 201 ------- magicblock-config/src/types/remote.rs | 491 ++++++++++++++++++ test-integration/Cargo.lock | 66 +-- test-integration/configs/api-conf.ephem.toml | 8 +- .../configs/chainlink-conf.devnet.toml | 8 +- test-integration/configs/claim-fees-test.toml | 8 +- .../configs/cloning-conf.devnet.toml | 8 +- .../configs/cloning-conf.ephem.toml | 8 +- .../configs/committor-conf.devnet.toml | 8 +- .../configs/config-conf.devnet.toml | 8 +- .../configs/restore-ledger-conf.devnet.toml | 8 +- .../configs/schedule-task.devnet.toml | 8 +- .../configs/schedule-task.ephem.toml | 8 +- .../schedulecommit-conf-fees.ephem.toml | 8 +- .../configs/schedulecommit-conf.devnet.toml | 8 +- ...ulecommit-conf.ephem.frequent-commits.toml | 8 +- .../configs/schedulecommit-conf.ephem.toml | 8 +- .../configs/validator-offline.devnet.toml | 8 +- test-integration/test-config/src/lib.rs | 14 +- .../tests/auto_airdrop_feepayer.rs | 14 +- .../test-ledger-restore/src/lib.rs | 14 +- .../test-task-scheduler/src/lib.rs | 14 +- .../test-tools/src/toml_to_args.rs | 28 +- 31 files changed, 1047 insertions(+), 367 deletions(-) delete mode 100644 magicblock-config/src/types/network.rs create mode 100644 magicblock-config/src/types/remote.rs diff --git a/Cargo.lock b/Cargo.lock index 248f1ad5b..69310c0b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -480,6 +480,24 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.9.1", + "cexpr", + "clang-sys", + "itertools 0.10.5", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.104", +] + [[package]] name = "bitflags" version = "1.3.2" diff --git a/config.example.toml b/config.example.toml index aae1a3df6..581c36f5e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -31,31 +31,47 @@ # Env: MBV_LIFECYCLE lifecycle = "ephemeral" -# Remote endpoints for syncing with the base chain. -# You can specify multiple remotes of different types. -# The first HTTP/HTTPS remote will be used for JSON-RPC calls. -# Each WebSocket/gRPC remote creates a subscription client. +# Remote connections for RPC, WebSocket, and gRPC. +# You can define multiple remotes of different kinds. +# The first RPC remote will be used for JSON-RPC calls. +# Each WebSocket/gRPC remote will create a pubsub client. # -# Supported URL schemes: -# - "http", "https": JSON-RPC HTTP connections -# - "ws", "wss": WebSocket connections for PubSub -# - "grpc", "grpcs": gRPC connections for streaming +# Available kinds: +# - "rpc": JSON-RPC HTTP connection (typically port 8899) +# - "websocket": WebSocket connection for PubSub (typically wss://) +# - "grpc": gRPC connection for streaming (typically port 50051) # -# URL Aliases (resolved during parsing): -# - "mainnet": resolves to https://api.mainnet-beta.solana.com/ -# - "devnet": resolves to https://api.devnet.solana.com/ -# - "testnet": resolves to https://api.testnet.solana.com/ -# - "localhost": resolves to http://localhost:8899/ (only for http/https schemes) +# URL Aliases (automatically resolved based on kind): +# RPC aliases: "mainnet", "devnet", "local" +# WebSocket aliases: "mainnet", "devnet", "local" # -# Examples: -# remotes = ["devnet"] # Single devnet HTTP endpoint -# remotes = ["mainnet", "wss://mainnet-beta.solana.com"] # Mainnet with explicit WebSocket -# remotes = ["http://localhost:8899", "ws://localhost:8900"] # Local endpoints +# Example 1: Using aliases +# [[remote]] +# kind = "rpc" +# url = "devnet" # -# If no remotes are specified, defaults to ["devnet"] with an auto-added WebSocket endpoint. -# Default: ["https://api.devnet.solana.com/"] -# Env: Not supported (must be configured via TOML or CLI) -remotes = ["devnet", "wss://devnet.solana.com", "grpcs://solana.helius.com"] +# [[remote]] +# kind = "websocket" +# url = "devnet" +# +# Example 2: Using full URLs with optional API key +# [[remote]] +# kind = "rpc" +# url = "https://api.devnet.solana.com" +# api-key = "optional-key" +# +# [[remote]] +# kind = "websocket" +# url = "wss://api.devnet.solana.com" +# +# [[remote]] +# kind = "grpc" +# url = "http://grpc.example.com:50051" +# api-key = "optional-key" + +[[remote]] +kind = "rpc" +url = "devnet" # Root directory for application storage (ledger, accountsdb, snapshots). # Default: "magicblock-test-storage" (created in current working directory) diff --git a/magicblock-api/src/magic_validator.rs b/magicblock-api/src/magic_validator.rs index 5b1015c81..3dc7d8734 100644 --- a/magicblock-api/src/magic_validator.rs +++ b/magicblock-api/src/magic_validator.rs @@ -37,6 +37,8 @@ use magicblock_config::{ config::{ ChainOperationConfig, LedgerConfig, LifecycleMode, LoadableProgram, }, + consts::DEFAULT_REMOTE, + types::{resolve_url, RemoteKind}, ValidatorParams, }; use magicblock_core::{ @@ -340,7 +342,7 @@ impl MagicValidator { config.validator.keypair.insecure_clone(), committor_persist_path, ChainConfig { - rpc_uri: config.rpc_url().to_owned(), + rpc_uri: config.rpc_url_or_default(), commitment: CommitmentConfig::confirmed(), compute_budget_config: ComputeBudgetConfig::new( config.commit.compute_unit_price, @@ -371,14 +373,22 @@ impl MagicValidator { faucet_pubkey: Pubkey, ) -> ApiResult { use magicblock_chainlink::remote_account_provider::Endpoint; - let rpc_url = config.rpc_url().to_owned(); - let endpoints = config - .websocket_urls() - .map(|pubsub_url| Endpoint { + let rpc_url = config.rpc_url_or_default(); + let endpoints = if config.has_subscription_url() { + config + .websocket_urls() + .map(|pubsub_url| Endpoint { + rpc_url: rpc_url.clone(), + pubsub_url: pubsub_url.to_string(), + }) + .collect::>() + } else { + let ws_url = resolve_url(RemoteKind::Websocket, DEFAULT_REMOTE); + vec![Endpoint { rpc_url: rpc_url.clone(), - pubsub_url: pubsub_url.to_string(), - }) - .collect::>(); + pubsub_url: ws_url, + }] + }; let cloner = ChainlinkCloner::new( committor_service, @@ -530,7 +540,7 @@ impl MagicValidator { }); DomainRegistryManager::handle_registration_static( - self.config.rpc_url(), + self.config.rpc_url_or_default(), &validator_keypair, validator_info, ) @@ -543,7 +553,7 @@ impl MagicValidator { let validator_keypair = validator_authority(); DomainRegistryManager::handle_unregistration_static( - self.config.rpc_url(), + self.config.rpc_url_or_default(), &validator_keypair, ) .map_err(|err| { @@ -557,7 +567,7 @@ impl MagicValidator { const MIN_BALANCE_SOL: u64 = 5; let lamports = RpcClient::new_with_commitment( - self.config.rpc_url().to_owned(), + self.config.rpc_url_or_default(), CommitmentConfig::confirmed(), ) .get_balance(&self.identity) @@ -604,7 +614,7 @@ impl MagicValidator { .map(|co| co.claim_fees_frequency) { self.claim_fees_task - .start(frequency, self.config.rpc_url().to_owned()); + .start(frequency, self.config.rpc_url_or_default()); } self.slot_ticker = Some(init_slot_ticker( diff --git a/magicblock-config/src/config/cli.rs b/magicblock-config/src/config/cli.rs index 36774e71c..d24ecd902 100644 --- a/magicblock-config/src/config/cli.rs +++ b/magicblock-config/src/config/cli.rs @@ -5,7 +5,9 @@ use serde::Serialize; use crate::{ config::LifecycleMode, - types::{network::Remote, BindAddress, SerdeKeypair}, + types::{ + remote::parse_remote_config, BindAddress, RemoteConfig, SerdeKeypair, + }, }; /// CLI Arguments mirroring the structure of ValidatorParams. @@ -16,22 +18,13 @@ pub struct CliParams { /// Path to the TOML configuration file. pub config: Option, - /// List of remote endpoints for syncing with the base chain. - /// Can be specified multiple times. - /// - /// SUPPORTED SCHEMES: http(s), ws(s), grpc(s) - /// - /// ALIASES: mainnet, devnet, testnet, localhost - /// - /// EXAMPLES: - /// - `--remote devnet` - /// - `--remote wss://devnet.solana.com` - /// - `--remote grpcs://grpc.example.com` - /// - /// DEFAULT: devnet (HTTP endpoint with auto-added WS endpoint) - #[arg(long)] - #[serde(skip_serializing_if = "Option::is_none")] - pub remotes: Option>, + /// Remote Solana cluster connections. Can be specified multiple times. + /// Format: --remote : or --remote : + /// Examples: --remote rpc:devnet --remote websocket:devnet --remote grpc:http://localhost:50051 + /// Aliases: mainnet, devnet, local (resolved based on kind) + #[arg(long, short, value_parser = parse_remote_config)] + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub remote: Vec, /// The application's operational mode. #[arg(long)] diff --git a/magicblock-config/src/consts.rs b/magicblock-config/src/consts.rs index 4eb88d48b..f4ca8bcd3 100644 --- a/magicblock-config/src/consts.rs +++ b/magicblock-config/src/consts.rs @@ -20,9 +20,15 @@ pub const DEFAULT_BASE_FEE: u64 = 0; /// Default compute unit price in microlamports pub const DEFAULT_COMPUTE_UNIT_PRICE: u64 = 1_000_000; -/// Remote URL Aliases - Mainnet, Testnet, Devnet, and Localhost -/// Solana mainnet-beta RPC endpoint -pub const MAINNET_URL: &str = "https://api.mainnet-beta.solana.com/"; +// Remote URL Aliases - RPC +pub const RPC_MAINNET: &str = "https://api.mainnet-beta.solana.com/"; +pub const RPC_DEVNET: &str = "https://api.devnet.solana.com/"; +pub const RPC_LOCAL: &str = "http://localhost:8899/"; + +// Remote URL Aliases - WebSocket +pub const WS_MAINNET: &str = "wss://api.mainnet-beta.solana.com/"; +pub const WS_DEVNET: &str = "wss://api.devnet.solana.com/"; +pub const WS_LOCAL: &str = "ws://localhost:8899/"; /// Solana testnet RPC endpoint pub const TESTNET_URL: &str = "https://api.testnet.solana.com/"; diff --git a/magicblock-config/src/lib.rs b/magicblock-config/src/lib.rs index 5de7369b9..81ca319be 100644 --- a/magicblock-config/src/lib.rs +++ b/magicblock-config/src/lib.rs @@ -24,7 +24,7 @@ use crate::{ CommittorConfig, LedgerConfig, LoadableProgram, TaskSchedulerConfig, ValidatorConfig, }, - types::{network::Remote, BindAddress}, + types::{resolve_url, BindAddress, RemoteConfig, RemoteKind}, }; /// Top-level configuration, assembled from multiple sources. @@ -34,9 +34,10 @@ pub struct ValidatorParams { /// Path to the TOML configuration file (overrides CLI args). pub config: Option, - /// Remote endpoints for syncing with the base chain. - /// Can include HTTP (for JSON-RPC), WebSocket (for PubSub), and gRPC (for streaming) connections. - pub remotes: Vec, + /// Array-based remote configurations for RPC, WebSocket, and gRPC. + /// Configured via [[remote]] sections in TOML (array-of-tables syntax). + #[serde(default, rename = "remote")] + pub remotes: Vec, /// The application's operational mode. pub lifecycle: LifecycleMode, @@ -173,6 +174,44 @@ impl ValidatorParams { .filter(|r| matches!(r, Remote::Grpc(_))) .map(|r| r.url_str()) } + + /// Returns the first RPC remote URL as an Option. + pub fn rpc_url(&self) -> Option<&str> { + self.remotes + .iter() + .find(|r| r.kind == RemoteKind::Rpc) + .map(|r| r.url.as_str()) + } + + /// Returns an iterator over all WebSocket remote URLs. + pub fn websocket_urls(&self) -> impl Iterator + '_ { + self.remotes + .iter() + .filter(|r| r.kind == RemoteKind::Websocket) + .map(|r| r.url.as_str()) + } + + /// Returns an iterator over all gRPC remote URLs. + pub fn grpc_urls(&self) -> impl Iterator + '_ { + self.remotes + .iter() + .filter(|r| r.kind == RemoteKind::Grpc) + .map(|r| r.url.as_str()) + } + + pub fn has_subscription_url(&self) -> bool { + self.remotes.iter().any(|r| { + r.kind == RemoteKind::Websocket || r.kind == RemoteKind::Grpc + }) + } + + /// Returns the RPC URL, using DEFAULT_REMOTE as fallback if not + /// configured. + pub fn rpc_url_or_default(&self) -> String { + self.rpc_url().map(|s| s.to_string()).unwrap_or_else(|| { + resolve_url(RemoteKind::Rpc, consts::DEFAULT_REMOTE) + }) + } } impl Display for ValidatorParams { diff --git a/magicblock-config/src/tests.rs b/magicblock-config/src/tests.rs index c55cc0622..a29a0afdb 100644 --- a/magicblock-config/src/tests.rs +++ b/magicblock-config/src/tests.rs @@ -8,7 +8,7 @@ use tempfile::TempDir; use crate::{ config::{BlockSize, LifecycleMode}, consts::{self, DEFAULT_VALIDATOR_KEYPAIR}, - types::network::Remote, + types::{remote::resolve_url, RemoteConfig, RemoteKind}, ValidatorParams, }; @@ -63,8 +63,8 @@ fn test_defaults_are_sane() { // Verify key defaults used in production assert_eq!(config.validator.basefee, consts::DEFAULT_BASE_FEE); - // Remotes default to [devnet HTTP] + [devnet WS] (added by ensure_websocket) - assert_eq!(config.remotes.len(), 2); + // Remotes default to empty when not specified + assert_eq!(config.remotes.len(), 0); assert_eq!(config.listen.0.port(), 8899); assert_eq!(config.lifecycle, LifecycleMode::Ephemeral); @@ -359,9 +359,10 @@ fn test_example_config_full_coverage() { // 3. Core & Network // ======================================================================== assert_eq!(config.lifecycle, LifecycleMode::Ephemeral); - // Example config has 3 remotes: devnet HTTP, devnet WebSocket, and Helius gRPC - assert_eq!(config.remotes.len(), 3); - assert_eq!(config.remotes[0].url_str(), consts::DEVNET_URL); + // Example config has one RPC remote with "devnet" alias resolved + assert_eq!(config.remotes.len(), 1); + assert_eq!(config.remotes[0].url, consts::RPC_DEVNET); + assert_eq!(config.remotes[0].kind, RemoteKind::Rpc); assert_eq!(config.listen.0.port(), 8899); // Check that storage path is set (contains the expected folder name) assert!(config @@ -492,8 +493,8 @@ fn test_env_vars_full_coverage() { // Core assert_eq!(config.lifecycle, LifecycleMode::Replica); - // Remotes default to devnet (HTTP) + devnet WebSocket (added by ensure_websocket) - assert_eq!(config.remotes.len(), 2); + // Remotes must be configured via TOML, not env vars + assert_eq!(config.remotes.len(), 0); assert_eq!(config.storage.to_string_lossy(), "/tmp/env-test-storage"); assert_eq!(config.listen.0.port(), 9999); @@ -541,46 +542,237 @@ fn test_env_vars_full_coverage() { } // ============================================================================ -// 9. Remote Type Parsing +// 9. New Remote Config Parsing // ============================================================================ #[test] #[parallel] -fn test_parse_http_remote() { - let remote: Remote = "http://localhost:8899".parse().unwrap(); - assert!(matches!(remote, Remote::Http(_))); - assert_eq!(remote.url_str(), "http://localhost:8899/"); +fn test_parse_single_rpc_remote() { + let (_dir, config_path) = create_temp_config( + r#" + [[remote]] + kind = "rpc" + url = "http://localhost:8899" + "#, + ); + + let config = run_cli(vec![config_path.to_str().unwrap()]); + + assert_eq!(config.remotes.len(), 1); + assert_eq!(config.remotes[0].kind, RemoteKind::Rpc); + assert_eq!(config.remotes[0].url, "http://localhost:8899"); + assert_eq!(config.remotes[0].api_key, None); } #[test] #[parallel] -fn test_parse_websocket_remote() { - let remote: Remote = "ws://localhost:8900".parse().unwrap(); - assert!(matches!(remote, Remote::Websocket(_))); +fn test_parse_rpc_remote_with_api_key() { + let (_dir, config_path) = create_temp_config( + r#" + [[remote]] + kind = "rpc" + url = "https://api.example.com" + api-key = "secret-key-123" + "#, + ); + + let config = run_cli(vec![config_path.to_str().unwrap()]); + + assert_eq!(config.remotes.len(), 1); + assert_eq!(config.remotes[0].kind, RemoteKind::Rpc); + assert_eq!(config.remotes[0].url, "https://api.example.com"); + assert_eq!( + config.remotes[0].api_key, + Some("secret-key-123".to_string()) + ); } #[test] #[parallel] -fn test_parse_grpc_remote_converts_scheme() { - let remote: Remote = "grpc://localhost:50051/".parse().unwrap(); - assert!(matches!(remote, Remote::Grpc(_))); - // Scheme should be converted to http - assert_eq!(remote.url_str(), "http://localhost:50051/"); +fn test_parse_multiple_remotes_mixed_kinds() { + let (_dir, config_path) = create_temp_config( + r#" + [[remote]] + kind = "rpc" + url = "http://localhost:8899" + + [[remote]] + kind = "websocket" + url = "wss://mainnet-beta.solana.com" + + [[remote]] + kind = "websocket" + url = "wss://backup-node.example.com" + + [[remote]] + kind = "grpc" + url = "http://grpc.example.com:50051" + api-key = "grpc-secret" + "#, + ); + + let config = run_cli(vec![config_path.to_str().unwrap()]); + + assert_eq!(config.remotes.len(), 4); + + // First: RPC + assert_eq!(config.remotes[0].kind, RemoteKind::Rpc); + assert_eq!(config.remotes[0].url, "http://localhost:8899"); + + // Second: WebSocket + assert_eq!(config.remotes[1].kind, RemoteKind::Websocket); + assert_eq!(config.remotes[1].url, "wss://mainnet-beta.solana.com"); + assert_eq!(config.remotes[1].api_key, None); + + // Third: WebSocket (multiple remotes of the same kind allowed) + assert_eq!(config.remotes[2].kind, RemoteKind::Websocket); + assert_eq!(config.remotes[2].url, "wss://backup-node.example.com"); + + // Fourth: gRPC + assert_eq!(config.remotes[3].kind, RemoteKind::Grpc); + assert_eq!(config.remotes[3].url, "http://grpc.example.com:50051"); + assert_eq!(config.remotes[3].api_key, Some("grpc-secret".to_string())); } #[test] #[parallel] -fn test_parse_alias() { - let remote: Remote = "devnet".parse().unwrap(); - assert!(matches!(remote, Remote::Http(_))); - assert_eq!(remote.url_str(), consts::DEVNET_URL); +fn test_parse_remotes_empty_when_not_provided() { + let (_dir, config_path) = create_temp_config( + r#" + lifecycle = "ephemeral" + "#, + ); + + let config = run_cli(vec![config_path.to_str().unwrap()]); + + assert_eq!(config.remotes.len(), 0); +} + +#[test] +#[parallel] +fn test_deserialization_resolves_aliases() { + // Verify that aliases are resolved during deserialization, + // not just in resolved_url() method + let (_dir, config_path) = create_temp_config( + r#" + [[remote]] + kind = "rpc" + url = "devnet" + + [[remote]] + kind = "websocket" + url = "mainnet" + "#, + ); + + let config = run_cli(vec![config_path.to_str().unwrap()]); + + assert_eq!(config.remotes.len(), 2); + + // RPC remote should have devnet alias resolved to actual URL + assert_eq!(config.remotes[0].kind, RemoteKind::Rpc); + assert_eq!(config.remotes[0].url, consts::RPC_DEVNET); + + // WebSocket remote should have mainnet alias resolved to actual URL + assert_eq!(config.remotes[1].kind, RemoteKind::Websocket); + assert_eq!(config.remotes[1].url, consts::WS_MAINNET); +} + +#[test] +#[parallel] +fn test_remote_config_parse_url_method() { + let remote = RemoteConfig { + kind: RemoteKind::Rpc, + url: "https://api.example.com".to_string(), + api_key: None, + }; + + let parsed_url = remote.parse_url(); + assert!(parsed_url.is_ok()); + let url = parsed_url.unwrap(); + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host_str(), Some("api.example.com")); +} + +#[test] +#[parallel] +fn test_remote_config_invalid_url() { + let remote = RemoteConfig { + kind: RemoteKind::Rpc, + url: "not a valid url".to_string(), + api_key: None, + }; + + let parsed_url = remote.parse_url(); + assert!(parsed_url.is_err()); +} + +#[test] +#[parallel] +fn test_rpc_alias_resolution() { + assert_eq!(resolve_url(RemoteKind::Rpc, "mainnet"), consts::RPC_MAINNET); + assert_eq!(resolve_url(RemoteKind::Rpc, "devnet"), consts::RPC_DEVNET); + assert_eq!(resolve_url(RemoteKind::Rpc, "local"), consts::RPC_LOCAL); +} + +#[test] +#[parallel] +fn test_websocket_alias_resolution() { + assert_eq!( + resolve_url(RemoteKind::Websocket, "mainnet"), + consts::WS_MAINNET + ); + assert_eq!( + resolve_url(RemoteKind::Websocket, "devnet"), + consts::WS_DEVNET + ); + assert_eq!( + resolve_url(RemoteKind::Websocket, "local"), + consts::WS_LOCAL + ); +} + +#[test] +#[parallel] +fn test_alias_resolution_same_alias_different_kinds() { + let rpc_resolved = resolve_url(RemoteKind::Rpc, "mainnet"); + let ws_resolved = resolve_url(RemoteKind::Websocket, "mainnet"); + + assert_eq!(rpc_resolved, consts::RPC_MAINNET); + assert_eq!(ws_resolved, consts::WS_MAINNET); + // They should be different + assert_ne!(rpc_resolved, ws_resolved); +} + +#[test] +#[parallel] +fn test_full_url_not_treated_as_alias() { + assert_eq!( + resolve_url(RemoteKind::Rpc, "https://custom-node.example.com"), + "https://custom-node.example.com" + ); + assert_eq!( + resolve_url(RemoteKind::Websocket, "wss://custom-node.example.com"), + "wss://custom-node.example.com" + ); } #[test] #[parallel] -fn test_to_websocket_from_http() { - let remote: Remote = "http://localhost:8899".parse().unwrap(); - let ws_remote = remote.to_websocket().unwrap(); - assert!(matches!(ws_remote, Remote::Websocket(_))); - assert_eq!(ws_remote.url_str(), "ws://localhost:8900/"); +fn test_parse_url_with_alias() { + let resolved = resolve_url(RemoteKind::Rpc, "devnet"); + let remote = RemoteConfig { + kind: RemoteKind::Rpc, + url: resolved, + api_key: None, + }; + + let parsed = remote.parse_url(); + assert!(parsed.is_ok()); + let url = parsed.unwrap(); + + // Extract expected host from the canonical constant + let expected_url = url::Url::parse(consts::RPC_DEVNET) + .expect("Failed to parse RPC_DEVNET constant"); + assert_eq!(url.host_str(), expected_url.host_str()); } diff --git a/magicblock-config/src/types/mod.rs b/magicblock-config/src/types/mod.rs index bef4cce81..49e7c91f5 100644 --- a/magicblock-config/src/types/mod.rs +++ b/magicblock-config/src/types/mod.rs @@ -1,12 +1,12 @@ use std::{fmt::Display, path::PathBuf}; pub mod crypto; -pub mod network; +pub mod remote; // Re-export types for easy access pub use crypto::{SerdeKeypair, SerdePubkey}; use derive_more::{Deref, FromStr}; -pub use network::BindAddress; +pub use remote::{resolve_url, BindAddress, RemoteConfig, RemoteKind}; use serde_with::{DeserializeFromStr, SerializeDisplay}; use crate::consts; diff --git a/magicblock-config/src/types/network.rs b/magicblock-config/src/types/network.rs deleted file mode 100644 index cf3a78876..000000000 --- a/magicblock-config/src/types/network.rs +++ /dev/null @@ -1,201 +0,0 @@ -use std::{net::SocketAddr, str::FromStr}; - -use derive_more::{Deref, Display, FromStr}; -use serde::{Deserialize, Serialize}; -use serde_with::{DeserializeFromStr, SerializeDisplay}; -use url::Url; - -use crate::consts; - -/// A network bind address that can be parsed from a string like "0.0.0.0:8080". -#[derive( - Clone, Copy, Debug, Deserialize, Serialize, FromStr, Display, Deref, -)] -#[serde(transparent)] -pub struct BindAddress(pub SocketAddr); - -impl Default for BindAddress { - fn default() -> Self { - consts::DEFAULT_RPC_ADDR.parse().unwrap() - } -} - -impl BindAddress { - fn as_connect_addr(&self) -> SocketAddr { - use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; - - match self.0.ip() { - IpAddr::V4(ip) if ip.is_unspecified() => { - SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), self.0.port()) - } - IpAddr::V6(ip) if ip.is_unspecified() => { - SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), self.0.port()) - } - _ => self.0, - } - } - - pub fn http(&self) -> String { - format!("http://{}", self.as_connect_addr()) - } - - pub fn websocket(&self) -> String { - format!("ws://{}", self.as_connect_addr()) - } -} - -/// A connection to one or more remote clusters (e.g., "devnet"). -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] -#[serde(rename_all = "kebab-case", untagged)] -pub enum RemoteCluster { - Single(Remote), - Multiple(Vec), -} - -impl RemoteCluster { - pub fn http(&self) -> &Url { - let remote = match self { - Self::Single(r) => r, - Self::Multiple(rs) => { - rs.first().expect("non-empty remote cluster array") - } - }; - match remote { - Remote::Unified(url) => &url.0, - Remote::Disjointed { http, .. } => &http.0, - } - } - - pub fn websocket(&self) -> Box + '_> { - fn ws(remote: &Remote) -> Url { - match remote { - Remote::Unified(url) => { - let mut url = Url::clone(&url.0); - let scheme = - if url.scheme() == "https" { "wss" } else { "ws" }; - let _ = url.set_scheme(scheme); - if let Some(port) = url.port() { - // By solana convention, websocket listens on rpc port + 1 - let _ = url.set_port(Some(port + 1)); - } - url - } - Remote::Disjointed { ws, .. } => ws.0.clone(), - } - } - match self { - Self::Single(r) => Box::new(iter::once(ws(r))), - Self::Multiple(rs) => Box::new(rs.iter().map(ws)), - } - } -} - -impl FromStr for RemoteCluster { - type Err = url::ParseError; - fn from_str(s: &str) -> Result { - AliasedUrl::from_str(s).map(|url| Self::Single(Remote::Unified(url))) - } -} - -impl Default for RemoteCluster { - fn default() -> Self { - consts::DEFAULT_REMOTE - .parse() - .expect("Default remote should be valid") - } -} - -/// A connection to a single remote node. -#[serde_as] -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] -#[serde(rename_all = "kebab-case", untagged)] -pub enum Remote { - Http(AliasedUrl), - Websocket(AliasedUrl), - Grpc(AliasedUrl), -} - -impl FromStr for Remote { - type Err = url::ParseError; - - fn from_str(s: &str) -> Result { - // Handle non-standard schemes by detecting them before parsing - let mut s = s.to_owned(); - let is_grpc = s.starts_with("grpc"); - if is_grpc { - // SAFETY: - // We made sure that "grpc" is the prefix and we are not violating Unicode invariants - unsafe { s.as_bytes_mut()[0..4].copy_from_slice(b"http") }; - } - - let parsed = AliasedUrl::from_str(&s)?; - let remote = match parsed.0.scheme() { - _ if is_grpc => Self::Grpc(parsed), - "http" | "https" => Self::Http(parsed), - "ws" | "wss" => Self::Websocket(parsed), - _ => return Err(url::ParseError::InvalidDomainCharacter), - }; - Ok(remote) - } -} - -impl Remote { - /// Returns the URL as a string reference. - pub fn url_str(&self) -> &str { - match self { - Self::Http(u) => u.as_str(), - Self::Websocket(u) => u.as_str(), - Self::Grpc(u) => u.as_str(), - } - } - - /// Converts an HTTP remote to a WebSocket remote by deriving the appropriate WebSocket URL. - pub(crate) fn to_websocket(&self) -> Option { - let mut url = match self { - Self::Websocket(_) => return Some(self.clone()), - Self::Grpc(_) => return None, - Self::Http(u) => u.0.clone(), - }; - let _ = if url.scheme() == "http" { - url.set_scheme("ws") - } else { - url.set_scheme("wss") - }; - if let Some(port) = url.port() { - // As per solana convention websocket port is one greater than http - let _ = url.set_port(Some(port + 1)); - } - Some(Self::Websocket(AliasedUrl(url))) - } -} - -/// A URL that can be aliased with shortcuts like "mainnet". -/// -/// Aliases are resolved during parsing and replaced with their full URLs. -#[derive( - Clone, Debug, Deserialize, SerializeDisplay, Display, PartialEq, Deref, -)] -pub struct AliasedUrl(pub Url); - -impl AliasedUrl { - /// Returns the URL as a string reference. - pub fn as_str(&self) -> &str { - self.0.as_str() - } -} - -impl FromStr for AliasedUrl { - type Err = url::ParseError; - - /// Parses a string into an AliasedUrl, resolving known aliases to their full URLs. - fn from_str(s: &str) -> Result { - let url_str = match s { - "mainnet" => consts::MAINNET_URL, - "devnet" => consts::DEVNET_URL, - "testnet" => consts::TESTNET_URL, - "localhost" | "dev" => consts::LOCALHOST_URL, - custom => custom, - }; - Url::parse(url_str).map(Self) - } -} diff --git a/magicblock-config/src/types/remote.rs b/magicblock-config/src/types/remote.rs new file mode 100644 index 000000000..99c8d4544 --- /dev/null +++ b/magicblock-config/src/types/remote.rs @@ -0,0 +1,491 @@ +use std::net::SocketAddr; + +use derive_more::{Deref, Display, FromStr}; +use serde::{ + de::{self, MapAccess, Visitor}, + Deserialize, Deserializer, Serialize, +}; +use url::Url; + +use crate::consts; + +/// A network bind address that can be parsed from a string like "0.0.0.0:8080". +#[derive( + Clone, Copy, Debug, Deserialize, Serialize, FromStr, Display, Deref, +)] +#[serde(transparent)] +pub struct BindAddress(pub SocketAddr); + +impl Default for BindAddress { + fn default() -> Self { + consts::DEFAULT_RPC_ADDR.parse().unwrap() + } +} + +impl BindAddress { + fn as_connect_addr(&self) -> SocketAddr { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + match self.0.ip() { + IpAddr::V4(ip) if ip.is_unspecified() => { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), self.0.port()) + } + IpAddr::V6(ip) if ip.is_unspecified() => { + SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), self.0.port()) + } + _ => self.0, + } + } + + pub fn http(&self) -> String { + format!("http://{}", self.as_connect_addr()) + } + + pub fn websocket(&self) -> String { + format!("ws://{}", self.as_connect_addr()) + } +} + +/// The kind of remote connection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum RemoteKind { + /// JSON-RPC HTTP connection. + Rpc, + /// WebSocket connection used for subscriptions. + Websocket, + /// gRPC connection used for subscriptions. + Grpc, +} + +/// Configuration for a single remote connection. +/// Aliases in the URL field are automatically resolved during deserialization. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct RemoteConfig { + /// The kind of remote connection (rpc, websocket, grpc). + pub kind: RemoteKind, + + /// The resolved URL for this remote connection. + /// If an alias was used in the config, it is automatically expanded during deserialization. + pub url: String, + + /// Optional API key for authentication. + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, +} + +impl<'de> Deserialize<'de> for RemoteConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct RemoteConfigVisitor; + + impl<'de> Visitor<'de> for RemoteConfigVisitor { + type Value = RemoteConfig; + + fn expecting( + &self, + formatter: &mut std::fmt::Formatter, + ) -> std::fmt::Result { + formatter.write_str("a remote configuration object") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut kind: Option = None; + let mut url: Option = None; + let mut api_key: Option = None; + + while let Some(key) = map.next_key::()? { + match key.as_str() { + "kind" => kind = Some(map.next_value()?), + "url" => url = Some(map.next_value()?), + "api-key" => api_key = map.next_value()?, + _ => { + // Ignore unknown fields - let the value be consumed + let _ = map.next_value::()?; + } + } + } + + let kind = + kind.ok_or_else(|| de::Error::missing_field("kind"))?; + let url = url.ok_or_else(|| de::Error::missing_field("url"))?; + + // Resolve the URL alias based on the kind + let resolved_url = resolve_url(kind, &url); + + Ok(RemoteConfig { + kind, + url: resolved_url, + api_key, + }) + } + } + + deserializer.deserialize_map(RemoteConfigVisitor) + } +} + +impl RemoteConfig { + /// Parses the resolved URL and returns a valid `Url` object. + pub fn parse_url(&self) -> Result { + Url::parse(&self.url) + } +} + +/// Resolves aliases to a URL and passes through custom URLs unchanged. +pub fn resolve_url(kind: RemoteKind, url: &str) -> String { + match kind { + RemoteKind::Rpc => match url { + "mainnet" => consts::RPC_MAINNET.to_string(), + "devnet" => consts::RPC_DEVNET.to_string(), + "local" => consts::RPC_LOCAL.to_string(), + _ => url.to_string(), + }, + RemoteKind::Websocket => match url { + "mainnet" => consts::WS_MAINNET.to_string(), + "devnet" => consts::WS_DEVNET.to_string(), + "local" => consts::WS_LOCAL.to_string(), + _ => url.to_string(), + }, + RemoteKind::Grpc => url.to_string(), + } +} + +/// Parses CLI remote config argument in format: kind:url[?api-key=value] +/// Example: rpc:devnet, websocket:https://api.devnet.solana.com +/// Important: The URL can contain colons (https://), so we match 'kind:' +/// only if it's a valid kind at the start. +pub fn parse_remote_config(s: &str) -> Result { + // Find the kind by looking for a valid kind followed by a colon + let kinds = ["rpc", "websocket", "grpc"]; + let kind_and_rest = kinds + .iter() + .find_map(|k| { + let prefix = format!("{}:", k); + if s.starts_with(&prefix) { + Some((*k, &s[prefix.len()..])) + } else { + None + } + }) + .ok_or_else(|| { + "Remote format must start with 'kind:url' where kind is \ + one of: rpc, websocket, grpc. Example: 'rpc:devnet'" + .to_string() + })?; + + let kind = match kind_and_rest.0 { + "rpc" => RemoteKind::Rpc, + "websocket" => RemoteKind::Websocket, + "grpc" => RemoteKind::Grpc, + // SAFETY: we already excluded invalid kinds above + _ => unreachable!(), + }; + + let rest = kind_and_rest.1; + let (url, api_key) = if let Some((url, query)) = rest.split_once('?') { + // Parse query parameters to extract api-key regardless of order or other + // parameters. Split on '&' to get individual key=value pairs. + let api_key = query.split('&').find_map(|pair| { + pair.split_once('=').and_then(|(k, v)| { + if k == "api-key" { + Some(v.to_string()) + } else { + None + } + }) + }); + (url.to_string(), api_key) + } else { + (rest.to_string(), None) + }; + + // Validate that URL is not empty + if url.trim().is_empty() { + return Err( + "URL cannot be empty. Provide a valid URL or alias (mainnet, \ + devnet, local)" + .to_string(), + ); + } + + // Resolve the URL alias based on the kind + let resolved_url = resolve_url(kind, &url); + // Validate URL format for non-alias URLs + if !["mainnet", "devnet", "local"].contains(&url.as_str()) + && Url::parse(&resolved_url).is_err() + { + return Err(format!( + "Invalid URL format: '{}'. Expected a valid URL like 'http://localhost:8899'", + resolved_url + )); + } + + Ok(RemoteConfig { + kind, + url: resolved_url, + api_key, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_rpc_without_api_key() { + let result = parse_remote_config("rpc:https://api.devnet.solana.com"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.kind, RemoteKind::Rpc); + assert_eq!(config.url, "https://api.devnet.solana.com"); + assert_eq!(config.api_key, None); + } + + #[test] + fn test_parse_rpc_with_api_key() { + let result = parse_remote_config( + "rpc:https://api.devnet.solana.com?api-key=secret123", + ); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.kind, RemoteKind::Rpc); + assert_eq!(config.url, "https://api.devnet.solana.com"); + assert_eq!(config.api_key, Some("secret123".to_string())); + } + + #[test] + fn test_parse_websocket_without_api_key() { + let result = + parse_remote_config("websocket:wss://api.devnet.solana.com"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.kind, RemoteKind::Websocket); + assert_eq!(config.url, "wss://api.devnet.solana.com"); + assert_eq!(config.api_key, None); + } + + #[test] + fn test_parse_websocket_with_api_key() { + let result = parse_remote_config( + "websocket:wss://api.devnet.solana.com?api-key=mykey", + ); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.kind, RemoteKind::Websocket); + assert_eq!(config.url, "wss://api.devnet.solana.com"); + assert_eq!(config.api_key, Some("mykey".to_string())); + } + + #[test] + fn test_parse_grpc_without_api_key() { + let result = parse_remote_config("grpc:http://localhost:50051"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.kind, RemoteKind::Grpc); + assert_eq!(config.url, "http://localhost:50051"); + assert_eq!(config.api_key, None); + } + + #[test] + fn test_parse_grpc_with_api_key() { + let result = + parse_remote_config("grpc:http://localhost:50051?api-key=xyz"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.kind, RemoteKind::Grpc); + assert_eq!(config.url, "http://localhost:50051"); + assert_eq!(config.api_key, Some("xyz".to_string())); + } + + #[test] + fn test_parse_missing_kind() { + let result = parse_remote_config("https://api.devnet.solana.com"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Remote format must start with")); + } + + #[test] + fn test_parse_invalid_kind() { + let result = parse_remote_config("http:https://api.devnet.solana.com"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Remote format must start with")); + } + + #[test] + fn test_parse_empty_url() { + let result = parse_remote_config("rpc:"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("URL cannot be empty")); + } + + #[test] + fn test_parse_api_key_with_special_chars() { + let result = parse_remote_config( + "rpc:http://localhost:8899?api-key=abc_123-xyz", + ); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.api_key, Some("abc_123-xyz".to_string())); + } + + #[test] + fn test_parse_url_with_port() { + let result = parse_remote_config("rpc:http://localhost:8899"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.url, "http://localhost:8899"); + } + + #[test] + fn test_parse_url_with_path() { + let result = parse_remote_config("rpc:http://localhost:8899/v1"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.url, "http://localhost:8899/v1"); + } + + // ================================================================ + // Aliases + // ================================================================ + + #[test] + fn test_parse_rpc_mainnet_alias() { + let result = parse_remote_config("rpc:mainnet"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.kind, RemoteKind::Rpc); + assert_eq!(config.url, consts::RPC_MAINNET); + } + + #[test] + fn test_parse_rpc_devnet_alias() { + let result = parse_remote_config("rpc:devnet"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.kind, RemoteKind::Rpc); + assert_eq!(config.url, consts::RPC_DEVNET); + } + + #[test] + fn test_parse_rpc_local_alias() { + let result = parse_remote_config("rpc:local"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.kind, RemoteKind::Rpc); + assert_eq!(config.url, consts::RPC_LOCAL); + } + + #[test] + fn test_parse_websocket_mainnet_alias() { + let result = parse_remote_config("websocket:mainnet"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.kind, RemoteKind::Websocket); + assert_eq!(config.url, consts::WS_MAINNET); + } + + #[test] + fn test_parse_websocket_devnet_alias() { + let result = parse_remote_config("websocket:devnet"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.kind, RemoteKind::Websocket); + assert_eq!(config.url, consts::WS_DEVNET); + } + + #[test] + fn test_parse_websocket_local_alias() { + let result = parse_remote_config("websocket:local"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.kind, RemoteKind::Websocket); + assert_eq!(config.url, consts::WS_LOCAL); + } + + #[test] + fn test_parse_rpc_alias_with_api_key() { + let result = parse_remote_config("rpc:mainnet?api-key=secret"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.kind, RemoteKind::Rpc); + assert_eq!(config.url, consts::RPC_MAINNET); + assert_eq!(config.api_key, Some("secret".to_string())); + } + + #[test] + fn test_parse_websocket_alias_with_api_key() { + let result = parse_remote_config("websocket:devnet?api-key=mytoken"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.kind, RemoteKind::Websocket); + assert_eq!(config.url, consts::WS_DEVNET); + assert_eq!(config.api_key, Some("mytoken".to_string())); + } + + #[test] + fn test_parse_different_kinds_same_alias() { + // Same alias "mainnet" should resolve to different URLs based on kind + let rpc = parse_remote_config("rpc:mainnet").unwrap(); + let ws = parse_remote_config("websocket:mainnet").unwrap(); + + assert_eq!(rpc.kind, RemoteKind::Rpc); + assert_eq!(ws.kind, RemoteKind::Websocket); + assert_eq!(rpc.url, consts::RPC_MAINNET); + assert_eq!(ws.url, consts::WS_MAINNET); + assert_ne!(rpc.url, ws.url); + } + + #[test] + fn test_parse_api_key_with_other_parameters() { + // Test api-key is correctly extracted when other query parameters + // are present + let result = parse_remote_config( + "rpc:http://localhost:8899?timeout=30&api-key=secret123&retry=3", + ); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.api_key, Some("secret123".to_string())); + } + + #[test] + fn test_parse_api_key_first_parameter() { + // Test api-key is correctly extracted when it's the first parameter + let result = parse_remote_config( + "rpc:http://localhost:8899?api-key=mykey&timeout=30", + ); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.api_key, Some("mykey".to_string())); + } + + #[test] + fn test_parse_api_key_last_parameter() { + // Test api-key is correctly extracted when it's the last parameter + let result = parse_remote_config( + "rpc:http://localhost:8899?timeout=30&api-key=lastkey", + ); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.api_key, Some("lastkey".to_string())); + } + + #[test] + fn test_parse_other_parameters_without_api_key() { + // Test that other query parameters don't break when api-key is absent + let result = + parse_remote_config("rpc:http://localhost:8899?timeout=30&retry=3"); + assert!(result.is_ok()); + let config = result.unwrap(); + assert_eq!(config.api_key, None); + } +} diff --git a/test-integration/Cargo.lock b/test-integration/Cargo.lock index 116acb551..154e2a6a1 100644 --- a/test-integration/Cargo.lock +++ b/test-integration/Cargo.lock @@ -2049,10 +2049,10 @@ dependencies = [ [[package]] name = "guinea" -version = "0.5.0" +version = "0.4.2" dependencies = [ "bincode", - "magicblock-magic-program-api 0.5.0", + "magicblock-magic-program-api 0.4.2", "serde", "solana-program", ] @@ -3009,7 +3009,7 @@ dependencies = [ [[package]] name = "magicblock-account-cloner" -version = "0.5.0" +version = "0.4.2" dependencies = [ "async-trait", "bincode", @@ -3020,7 +3020,7 @@ dependencies = [ "magicblock-config", "magicblock-core", "magicblock-ledger", - "magicblock-magic-program-api 0.5.0", + "magicblock-magic-program-api 0.4.2", "magicblock-program", "magicblock-rpc-client", "rand 0.9.1", @@ -3041,7 +3041,7 @@ dependencies = [ [[package]] name = "magicblock-accounts" -version = "0.5.0" +version = "0.4.2" dependencies = [ "async-trait", "log", @@ -3063,7 +3063,7 @@ dependencies = [ [[package]] name = "magicblock-accounts-db" -version = "0.5.0" +version = "0.4.2" dependencies = [ "lmdb-rkv", "log", @@ -3079,7 +3079,7 @@ dependencies = [ [[package]] name = "magicblock-aperture" -version = "0.5.0" +version = "0.4.2" dependencies = [ "arc-swap", "base64 0.21.7", @@ -3124,7 +3124,7 @@ dependencies = [ [[package]] name = "magicblock-api" -version = "0.5.0" +version = "0.4.2" dependencies = [ "anyhow", "borsh 1.5.7", @@ -3140,7 +3140,7 @@ dependencies = [ "magicblock-config", "magicblock-core", "magicblock-ledger", - "magicblock-magic-program-api 0.5.0", + "magicblock-magic-program-api 0.4.2", "magicblock-metrics", "magicblock-processor", "magicblock-program", @@ -3179,7 +3179,7 @@ dependencies = [ [[package]] name = "magicblock-chainlink" -version = "0.5.0" +version = "0.4.2" dependencies = [ "arc-swap", "async-trait", @@ -3191,7 +3191,7 @@ dependencies = [ "magicblock-config", "magicblock-core", "magicblock-delegation-program", - "magicblock-magic-program-api 0.5.0", + "magicblock-magic-program-api 0.4.2", "magicblock-metrics", "solana-account", "solana-account-decoder", @@ -3226,7 +3226,7 @@ dependencies = [ [[package]] name = "magicblock-committor-program" -version = "0.5.0" +version = "0.4.2" dependencies = [ "borsh 1.5.7", "paste", @@ -3238,7 +3238,7 @@ dependencies = [ [[package]] name = "magicblock-committor-service" -version = "0.5.0" +version = "0.4.2" dependencies = [ "async-trait", "base64 0.21.7", @@ -3281,7 +3281,7 @@ dependencies = [ [[package]] name = "magicblock-config" -version = "0.5.0" +version = "0.4.2" dependencies = [ "clap", "derive_more", @@ -3299,10 +3299,10 @@ dependencies = [ [[package]] name = "magicblock-core" -version = "0.5.0" +version = "0.4.2" dependencies = [ "flume", - "magicblock-magic-program-api 0.5.0", + "magicblock-magic-program-api 0.4.2", "solana-account", "solana-account-decoder", "solana-hash", @@ -3337,7 +3337,7 @@ dependencies = [ [[package]] name = "magicblock-ledger" -version = "0.5.0" +version = "0.4.2" dependencies = [ "arc-swap", "bincode", @@ -3387,7 +3387,7 @@ dependencies = [ [[package]] name = "magicblock-magic-program-api" -version = "0.5.0" +version = "0.4.2" dependencies = [ "bincode", "serde", @@ -3396,7 +3396,7 @@ dependencies = [ [[package]] name = "magicblock-metrics" -version = "0.5.0" +version = "0.4.2" dependencies = [ "http-body-util", "hyper 1.6.0", @@ -3410,7 +3410,7 @@ dependencies = [ [[package]] name = "magicblock-processor" -version = "0.5.0" +version = "0.4.2" dependencies = [ "bincode", "log", @@ -3444,12 +3444,12 @@ dependencies = [ [[package]] name = "magicblock-program" -version = "0.5.0" +version = "0.4.2" dependencies = [ "bincode", "lazy_static", "magicblock-core", - "magicblock-magic-program-api 0.5.0", + "magicblock-magic-program-api 0.4.2", "num-derive", "num-traits", "parking_lot", @@ -3476,7 +3476,7 @@ dependencies = [ [[package]] name = "magicblock-rpc-client" -version = "0.5.0" +version = "0.4.2" dependencies = [ "log", "solana-account", @@ -3497,7 +3497,7 @@ dependencies = [ [[package]] name = "magicblock-table-mania" -version = "0.5.0" +version = "0.4.2" dependencies = [ "ed25519-dalek", "log", @@ -3523,7 +3523,7 @@ dependencies = [ [[package]] name = "magicblock-task-scheduler" -version = "0.5.0" +version = "0.4.2" dependencies = [ "bincode", "chrono", @@ -3549,7 +3549,7 @@ dependencies = [ [[package]] name = "magicblock-validator-admin" -version = "0.5.0" +version = "0.4.2" dependencies = [ "log", "magicblock-delegation-program", @@ -3566,7 +3566,7 @@ dependencies = [ [[package]] name = "magicblock-version" -version = "0.5.0" +version = "0.4.2" dependencies = [ "git-version", "rustc_version", @@ -4402,7 +4402,7 @@ dependencies = [ "bincode", "borsh 1.5.7", "ephemeral-rollups-sdk", - "magicblock-magic-program-api 0.5.0", + "magicblock-magic-program-api 0.4.2", "serde", "solana-program", ] @@ -4426,7 +4426,7 @@ dependencies = [ "borsh 1.5.7", "ephemeral-rollups-sdk", "magicblock-delegation-program", - "magicblock-magic-program-api 0.5.0", + "magicblock-magic-program-api 0.4.2", "solana-program", ] @@ -5276,7 +5276,7 @@ dependencies = [ "integration-test-tools", "log", "magicblock-core", - "magicblock-magic-program-api 0.5.0", + "magicblock-magic-program-api 0.4.2", "program-schedulecommit", "schedulecommit-client", "solana-program", @@ -5292,7 +5292,7 @@ version = "0.0.0" dependencies = [ "integration-test-tools", "magicblock-core", - "magicblock-magic-program-api 0.5.0", + "magicblock-magic-program-api 0.4.2", "program-schedulecommit", "program-schedulecommit-security", "schedulecommit-client", @@ -7972,7 +7972,7 @@ dependencies = [ [[package]] name = "solana-storage-proto" -version = "0.5.0" +version = "0.4.2" dependencies = [ "bincode", "bs58", @@ -9439,7 +9439,7 @@ dependencies = [ [[package]] name = "test-kit" -version = "0.5.0" +version = "0.4.2" dependencies = [ "env_logger 0.11.8", "guinea", diff --git a/test-integration/configs/api-conf.ephem.toml b/test-integration/configs/api-conf.ephem.toml index 6fe93ed65..00480a5f0 100644 --- a/test-integration/configs/api-conf.ephem.toml +++ b/test-integration/configs/api-conf.ephem.toml @@ -2,7 +2,13 @@ lifecycle = "ephemeral" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:8899" -remotes = ["http://127.0.0.1:7799", "ws://127.0.0.1:7800"] +[[remote]] +kind = "rpc" +url = "http://127.0.0.1:7799" + +[[remote]] +kind = "websocket" +url = "ws://127.0.0.1:7800" [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/chainlink-conf.devnet.toml b/test-integration/configs/chainlink-conf.devnet.toml index eed2730f5..ebac4d7af 100644 --- a/test-integration/configs/chainlink-conf.devnet.toml +++ b/test-integration/configs/chainlink-conf.devnet.toml @@ -2,7 +2,13 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -remotes = ["devnet"] +[[remote]] +kind = "rpc" +url = "devnet" + +[[remote]] +kind = "websocket" +url = "devnet" [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/claim-fees-test.toml b/test-integration/configs/claim-fees-test.toml index 12e0445aa..a6d2756c4 100644 --- a/test-integration/configs/claim-fees-test.toml +++ b/test-integration/configs/claim-fees-test.toml @@ -2,7 +2,13 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -remotes = ["devnet"] +[[remote]] +kind = "rpc" +url = "devnet" + +[[remote]] +kind = "websocket" +url = "devnet" [accountsdb] database-size = 1048576000 diff --git a/test-integration/configs/cloning-conf.devnet.toml b/test-integration/configs/cloning-conf.devnet.toml index a3e86ba80..5f7203788 100644 --- a/test-integration/configs/cloning-conf.devnet.toml +++ b/test-integration/configs/cloning-conf.devnet.toml @@ -2,7 +2,13 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -remotes = ["devnet"] +[[remote]] +kind = "rpc" +url = "devnet" + +[[remote]] +kind = "websocket" +url = "devnet" [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/cloning-conf.ephem.toml b/test-integration/configs/cloning-conf.ephem.toml index e4537421a..ca3ca0842 100644 --- a/test-integration/configs/cloning-conf.ephem.toml +++ b/test-integration/configs/cloning-conf.ephem.toml @@ -2,7 +2,13 @@ lifecycle = "ephemeral" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:8899" -remotes = ["http://0.0.0.0:7799", "ws://0.0.0.0:7800"] +[[remote]] +kind = "rpc" +url = "http://0.0.0.0:7799" + +[[remote]] +kind = "websocket" +url = "ws://0.0.0.0:7800" [chainlink] max-monitored-accounts = 3 diff --git a/test-integration/configs/committor-conf.devnet.toml b/test-integration/configs/committor-conf.devnet.toml index e58bec684..638f29fe9 100644 --- a/test-integration/configs/committor-conf.devnet.toml +++ b/test-integration/configs/committor-conf.devnet.toml @@ -2,7 +2,13 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -remotes = ["devnet"] +[[remote]] +kind = "rpc" +url = "devnet" + +[[remote]] +kind = "websocket" +url = "devnet" [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/config-conf.devnet.toml b/test-integration/configs/config-conf.devnet.toml index 6a18de264..36157ac24 100644 --- a/test-integration/configs/config-conf.devnet.toml +++ b/test-integration/configs/config-conf.devnet.toml @@ -2,7 +2,13 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -remotes = ["devnet"] +[[remote]] +kind = "rpc" +url = "devnet" + +[[remote]] +kind = "websocket" +url = "devnet" [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/restore-ledger-conf.devnet.toml b/test-integration/configs/restore-ledger-conf.devnet.toml index 66ab8c30d..716cc64a0 100644 --- a/test-integration/configs/restore-ledger-conf.devnet.toml +++ b/test-integration/configs/restore-ledger-conf.devnet.toml @@ -2,7 +2,13 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -remotes = ["devnet"] +[[remote]] +kind = "rpc" +url = "devnet" + +[[remote]] +kind = "websocket" +url = "devnet" [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/schedule-task.devnet.toml b/test-integration/configs/schedule-task.devnet.toml index 167bbdba6..2c7461b6d 100644 --- a/test-integration/configs/schedule-task.devnet.toml +++ b/test-integration/configs/schedule-task.devnet.toml @@ -2,7 +2,13 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -remotes = ["devnet"] +[[remote]] +kind = "rpc" +url = "devnet" + +[[remote]] +kind = "websocket" +url = "devnet" [ledger] block-time = "50ms" diff --git a/test-integration/configs/schedule-task.ephem.toml b/test-integration/configs/schedule-task.ephem.toml index e6d7dd573..85d819d24 100644 --- a/test-integration/configs/schedule-task.ephem.toml +++ b/test-integration/configs/schedule-task.ephem.toml @@ -2,7 +2,13 @@ lifecycle = "ephemeral" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:8899" -remotes = ["http://0.0.0.0:7799", "ws://0.0.0.0:7800"] +[[remote]] +kind = "rpc" +url = "http://0.0.0.0:7799" + +[[remote]] +kind = "websocket" +url = "ws://0.0.0.0:7800" [ledger] reset = true diff --git a/test-integration/configs/schedulecommit-conf-fees.ephem.toml b/test-integration/configs/schedulecommit-conf-fees.ephem.toml index f9920a2e9..4052b1611 100644 --- a/test-integration/configs/schedulecommit-conf-fees.ephem.toml +++ b/test-integration/configs/schedulecommit-conf-fees.ephem.toml @@ -2,7 +2,13 @@ lifecycle = "ephemeral" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:8899" -remotes = ["http://0.0.0.0:7799", "ws://0.0.0.0:7800"] +[[remote]] +kind = "rpc" +url = "http://0.0.0.0:7799" + +[[remote]] +kind = "websocket" +url = "ws://0.0.0.0:7800" [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/schedulecommit-conf.devnet.toml b/test-integration/configs/schedulecommit-conf.devnet.toml index a0fd36d6f..114b12802 100644 --- a/test-integration/configs/schedulecommit-conf.devnet.toml +++ b/test-integration/configs/schedulecommit-conf.devnet.toml @@ -2,7 +2,13 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -remotes = ["devnet"] +[[remote]] +kind = "rpc" +url = "devnet" + +[[remote]] +kind = "websocket" +url = "devnet" [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/schedulecommit-conf.ephem.frequent-commits.toml b/test-integration/configs/schedulecommit-conf.ephem.frequent-commits.toml index dd97438d5..c5fce6e88 100644 --- a/test-integration/configs/schedulecommit-conf.ephem.frequent-commits.toml +++ b/test-integration/configs/schedulecommit-conf.ephem.frequent-commits.toml @@ -2,7 +2,13 @@ lifecycle = "ephemeral" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:8899" -remotes = ["http://0.0.0.0:7799", "ws://0.0.0.0:7800"] +[[remote]] +kind = "rpc" +url = "http://0.0.0.0:7799" + +[[remote]] +kind = "websocket" +url = "ws://0.0.0.0:7800" [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/schedulecommit-conf.ephem.toml b/test-integration/configs/schedulecommit-conf.ephem.toml index afac5647a..f076492c7 100644 --- a/test-integration/configs/schedulecommit-conf.ephem.toml +++ b/test-integration/configs/schedulecommit-conf.ephem.toml @@ -2,7 +2,13 @@ lifecycle = "ephemeral" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:8899" -remotes = ["http://0.0.0.0:7799", "ws://0.0.0.0:7800"] +[[remote]] +kind = "rpc" +url = "http://0.0.0.0:7799" + +[[remote]] +kind = "websocket" +url = "ws://0.0.0.0:7800" [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/validator-offline.devnet.toml b/test-integration/configs/validator-offline.devnet.toml index 4e85c45cc..cf66953a3 100644 --- a/test-integration/configs/validator-offline.devnet.toml +++ b/test-integration/configs/validator-offline.devnet.toml @@ -2,7 +2,13 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -remotes = ["devnet"] +[[remote]] +kind = "rpc" +url = "devnet" + +[[remote]] +kind = "websocket" +url = "devnet" [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/test-config/src/lib.rs b/test-integration/test-config/src/lib.rs index 8a1940b02..8c2c0960f 100644 --- a/test-integration/test-config/src/lib.rs +++ b/test-integration/test-config/src/lib.rs @@ -14,7 +14,7 @@ use magicblock_config::{ accounts::AccountsDbConfig, chain::ChainLinkConfig, ledger::LedgerConfig, LifecycleMode, LoadableProgram, }, - types::{crypto::SerdePubkey, network::Remote}, + types::{crypto::SerdePubkey, RemoteConfig, RemoteKind}, ValidatorParams, }; use program_flexi_counter::instruction::{ @@ -45,8 +45,16 @@ pub fn start_validator_with_clone_config( programs, lifecycle: LifecycleMode::Ephemeral, remotes: vec![ - Remote::from_str(IntegrationTestContext::url_chain()).unwrap(), - Remote::from_str(IntegrationTestContext::ws_url_chain()).unwrap(), + RemoteConfig { + kind: RemoteKind::Rpc, + url: IntegrationTestContext::url_chain().to_string(), + api_key: None, + }, + RemoteConfig { + kind: RemoteKind::Websocket, + url: IntegrationTestContext::ws_url_chain().to_string(), + api_key: None, + }, ], chainlink: ChainLinkConfig { prepare_lookup_tables, diff --git a/test-integration/test-config/tests/auto_airdrop_feepayer.rs b/test-integration/test-config/tests/auto_airdrop_feepayer.rs index 5989e580f..85adba561 100644 --- a/test-integration/test-config/tests/auto_airdrop_feepayer.rs +++ b/test-integration/test-config/tests/auto_airdrop_feepayer.rs @@ -10,7 +10,7 @@ use magicblock_config::{ accounts::AccountsDbConfig, chain::ChainLinkConfig, ledger::LedgerConfig, LifecycleMode, }, - types::network::Remote, + types::{RemoteConfig, RemoteKind}, ValidatorParams, }; use solana_sdk::{signature::Keypair, signer::Signer, system_instruction}; @@ -24,8 +24,16 @@ fn test_auto_airdrop_feepayer_balance_after_tx() { let config = ValidatorParams { lifecycle: LifecycleMode::Ephemeral, remotes: vec![ - Remote::from_str(IntegrationTestContext::url_chain()).unwrap(), - Remote::from_str(IntegrationTestContext::ws_url_chain()).unwrap(), + RemoteConfig { + kind: RemoteKind::Rpc, + url: IntegrationTestContext::url_chain().to_string(), + api_key: None, + }, + RemoteConfig { + kind: RemoteKind::Websocket, + url: IntegrationTestContext::ws_url_chain().to_string(), + api_key: None, + }, ], accountsdb: AccountsDbConfig::default(), chainlink: ChainLinkConfig { diff --git a/test-integration/test-ledger-restore/src/lib.rs b/test-integration/test-ledger-restore/src/lib.rs index 58db033b5..117bc1b9c 100644 --- a/test-integration/test-ledger-restore/src/lib.rs +++ b/test-integration/test-ledger-restore/src/lib.rs @@ -20,7 +20,7 @@ use magicblock_config::{ LifecycleMode, LoadableProgram, }, consts::DEFAULT_LEDGER_BLOCK_TIME_MS, - types::{crypto::SerdePubkey, network::Remote, StorageDirectory}, + types::{crypto::SerdePubkey, RemoteConfig, RemoteKind, StorageDirectory}, ValidatorParams, }; use program_flexi_counter::{ @@ -148,8 +148,16 @@ pub fn setup_validator_with_local_remote_and_resume_strategy( task_scheduler: TaskSchedulerConfig { reset: true }, lifecycle: LifecycleMode::Ephemeral, remotes: vec![ - Remote::from_str(IntegrationTestContext::url_chain()).unwrap(), - Remote::from_str(IntegrationTestContext::ws_url_chain()).unwrap(), + RemoteConfig { + kind: RemoteKind::Rpc, + url: IntegrationTestContext::url_chain().to_string(), + api_key: None, + }, + RemoteConfig { + kind: RemoteKind::Websocket, + url: IntegrationTestContext::ws_url_chain().to_string(), + api_key: None, + }, ], storage: StorageDirectory(ledger_path.to_path_buf()), ..Default::default() diff --git a/test-integration/test-task-scheduler/src/lib.rs b/test-integration/test-task-scheduler/src/lib.rs index d819f7ed6..2154f573d 100644 --- a/test-integration/test-task-scheduler/src/lib.rs +++ b/test-integration/test-task-scheduler/src/lib.rs @@ -16,7 +16,7 @@ use magicblock_config::{ scheduler::TaskSchedulerConfig, validator::ValidatorConfig, LifecycleMode, }, - types::{network::Remote, StorageDirectory}, + types::{RemoteConfig, RemoteKind, StorageDirectory}, ValidatorParams, }; use program_flexi_counter::instruction::{ @@ -35,8 +35,16 @@ pub fn setup_validator() -> (TempDir, Child, IntegrationTestContext) { let config = ValidatorParams { lifecycle: LifecycleMode::Ephemeral, remotes: vec![ - Remote::from_str(IntegrationTestContext::url_chain()).unwrap(), - Remote::from_str(IntegrationTestContext::ws_url_chain()).unwrap(), + RemoteConfig { + kind: RemoteKind::Rpc, + url: IntegrationTestContext::url_chain().to_string(), + api_key: None, + }, + RemoteConfig { + kind: RemoteKind::Websocket, + url: IntegrationTestContext::ws_url_chain().to_string(), + api_key: None, + }, ], accountsdb: AccountsDbConfig::default(), task_scheduler: TaskSchedulerConfig { reset: true }, diff --git a/test-integration/test-tools/src/toml_to_args.rs b/test-integration/test-tools/src/toml_to_args.rs index 08b5af4f7..b4a038aac 100644 --- a/test-integration/test-tools/src/toml_to_args.rs +++ b/test-integration/test-tools/src/toml_to_args.rs @@ -4,7 +4,7 @@ use std::{ str::FromStr, }; -use magicblock_config::types::network::Remote; +use magicblock_config::types::{resolve_url, RemoteKind}; use serde::Deserialize; #[derive(Deserialize)] @@ -23,24 +23,18 @@ struct RemoteConfig { } impl RemoteConfig { - /// Returns the URL for this remote, parsing the kind and url into a Remote instance. + /// Returns the URL for this remote, resolving aliases based on kind. fn url(&self) -> String { - // Construct the full remote URL with the appropriate scheme - let full_url = match self.kind.as_str() { - "rpc" => format!("http://{}", self.url), - "websocket" => format!("ws://{}", self.url), - "grpc" => format!("grpc://{}", self.url), - // Default to http for unknown kinds - _ => format!("http://{}", self.url), + // Convert string kind to RemoteKind enum + let kind = match self.kind.as_str() { + "rpc" => RemoteKind::Rpc, + "websocket" => RemoteKind::Websocket, + "grpc" => RemoteKind::Grpc, + // Default to rpc for unknown kinds + _ => RemoteKind::Rpc, }; - - // Parse the full URL into a Remote instance to resolve aliases - if let Ok(remote) = Remote::from_str(&full_url) { - remote.url_str().to_string() - } else { - // If parsing fails, return the raw URL - self.url.clone() - } + // Use the production resolve_url function from magicblock-config + resolve_url(kind, &self.url) } } From 03c459c8d7cbaf5d86773cfb0de4aa729f9533bc Mon Sep 17 00:00:00 2001 From: Babur Makhmudov <31780624+bmuddha@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:41:21 +0400 Subject: [PATCH 11/11] refactor: optimize config crate structure and API (#752) --- Cargo.lock | 2 +- config.example.toml | 58 +-- magicblock-api/src/magic_validator.rs | 34 +- magicblock-config/src/config/cli.rs | 27 +- magicblock-config/src/consts.rs | 12 +- magicblock-config/src/lib.rs | 47 +- magicblock-config/src/tests.rs | 252 ++------- magicblock-config/src/types/mod.rs | 4 +- magicblock-config/src/types/network.rs | 143 +++++ magicblock-config/src/types/remote.rs | 491 ------------------ test-integration/Cargo.lock | 66 +-- test-integration/configs/api-conf.ephem.toml | 8 +- .../configs/chainlink-conf.devnet.toml | 8 +- test-integration/configs/claim-fees-test.toml | 8 +- .../configs/cloning-conf.devnet.toml | 8 +- .../configs/cloning-conf.ephem.toml | 8 +- .../configs/committor-conf.devnet.toml | 8 +- .../configs/config-conf.devnet.toml | 8 +- .../configs/restore-ledger-conf.devnet.toml | 8 +- .../configs/schedule-task.devnet.toml | 8 +- .../configs/schedule-task.ephem.toml | 8 +- .../schedulecommit-conf-fees.ephem.toml | 8 +- .../configs/schedulecommit-conf.devnet.toml | 8 +- ...ulecommit-conf.ephem.frequent-commits.toml | 8 +- .../configs/schedulecommit-conf.ephem.toml | 8 +- .../configs/validator-offline.devnet.toml | 8 +- test-integration/test-config/src/lib.rs | 14 +- .../tests/auto_airdrop_feepayer.rs | 14 +- .../test-ledger-restore/src/lib.rs | 14 +- .../test-task-scheduler/src/lib.rs | 14 +- .../test-tools/src/toml_to_args.rs | 28 +- 31 files changed, 310 insertions(+), 1030 deletions(-) create mode 100644 magicblock-config/src/types/network.rs delete mode 100644 magicblock-config/src/types/remote.rs diff --git a/Cargo.lock b/Cargo.lock index 69310c0b1..e8bd5f177 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -489,7 +489,7 @@ dependencies = [ "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.12.1", "proc-macro2", "quote", "regex", diff --git a/config.example.toml b/config.example.toml index 581c36f5e..aae1a3df6 100644 --- a/config.example.toml +++ b/config.example.toml @@ -31,47 +31,31 @@ # Env: MBV_LIFECYCLE lifecycle = "ephemeral" -# Remote connections for RPC, WebSocket, and gRPC. -# You can define multiple remotes of different kinds. -# The first RPC remote will be used for JSON-RPC calls. -# Each WebSocket/gRPC remote will create a pubsub client. +# Remote endpoints for syncing with the base chain. +# You can specify multiple remotes of different types. +# The first HTTP/HTTPS remote will be used for JSON-RPC calls. +# Each WebSocket/gRPC remote creates a subscription client. # -# Available kinds: -# - "rpc": JSON-RPC HTTP connection (typically port 8899) -# - "websocket": WebSocket connection for PubSub (typically wss://) -# - "grpc": gRPC connection for streaming (typically port 50051) +# Supported URL schemes: +# - "http", "https": JSON-RPC HTTP connections +# - "ws", "wss": WebSocket connections for PubSub +# - "grpc", "grpcs": gRPC connections for streaming # -# URL Aliases (automatically resolved based on kind): -# RPC aliases: "mainnet", "devnet", "local" -# WebSocket aliases: "mainnet", "devnet", "local" +# URL Aliases (resolved during parsing): +# - "mainnet": resolves to https://api.mainnet-beta.solana.com/ +# - "devnet": resolves to https://api.devnet.solana.com/ +# - "testnet": resolves to https://api.testnet.solana.com/ +# - "localhost": resolves to http://localhost:8899/ (only for http/https schemes) # -# Example 1: Using aliases -# [[remote]] -# kind = "rpc" -# url = "devnet" +# Examples: +# remotes = ["devnet"] # Single devnet HTTP endpoint +# remotes = ["mainnet", "wss://mainnet-beta.solana.com"] # Mainnet with explicit WebSocket +# remotes = ["http://localhost:8899", "ws://localhost:8900"] # Local endpoints # -# [[remote]] -# kind = "websocket" -# url = "devnet" -# -# Example 2: Using full URLs with optional API key -# [[remote]] -# kind = "rpc" -# url = "https://api.devnet.solana.com" -# api-key = "optional-key" -# -# [[remote]] -# kind = "websocket" -# url = "wss://api.devnet.solana.com" -# -# [[remote]] -# kind = "grpc" -# url = "http://grpc.example.com:50051" -# api-key = "optional-key" - -[[remote]] -kind = "rpc" -url = "devnet" +# If no remotes are specified, defaults to ["devnet"] with an auto-added WebSocket endpoint. +# Default: ["https://api.devnet.solana.com/"] +# Env: Not supported (must be configured via TOML or CLI) +remotes = ["devnet", "wss://devnet.solana.com", "grpcs://solana.helius.com"] # Root directory for application storage (ledger, accountsdb, snapshots). # Default: "magicblock-test-storage" (created in current working directory) diff --git a/magicblock-api/src/magic_validator.rs b/magicblock-api/src/magic_validator.rs index 3dc7d8734..5b1015c81 100644 --- a/magicblock-api/src/magic_validator.rs +++ b/magicblock-api/src/magic_validator.rs @@ -37,8 +37,6 @@ use magicblock_config::{ config::{ ChainOperationConfig, LedgerConfig, LifecycleMode, LoadableProgram, }, - consts::DEFAULT_REMOTE, - types::{resolve_url, RemoteKind}, ValidatorParams, }; use magicblock_core::{ @@ -342,7 +340,7 @@ impl MagicValidator { config.validator.keypair.insecure_clone(), committor_persist_path, ChainConfig { - rpc_uri: config.rpc_url_or_default(), + rpc_uri: config.rpc_url().to_owned(), commitment: CommitmentConfig::confirmed(), compute_budget_config: ComputeBudgetConfig::new( config.commit.compute_unit_price, @@ -373,22 +371,14 @@ impl MagicValidator { faucet_pubkey: Pubkey, ) -> ApiResult { use magicblock_chainlink::remote_account_provider::Endpoint; - let rpc_url = config.rpc_url_or_default(); - let endpoints = if config.has_subscription_url() { - config - .websocket_urls() - .map(|pubsub_url| Endpoint { - rpc_url: rpc_url.clone(), - pubsub_url: pubsub_url.to_string(), - }) - .collect::>() - } else { - let ws_url = resolve_url(RemoteKind::Websocket, DEFAULT_REMOTE); - vec![Endpoint { + let rpc_url = config.rpc_url().to_owned(); + let endpoints = config + .websocket_urls() + .map(|pubsub_url| Endpoint { rpc_url: rpc_url.clone(), - pubsub_url: ws_url, - }] - }; + pubsub_url: pubsub_url.to_string(), + }) + .collect::>(); let cloner = ChainlinkCloner::new( committor_service, @@ -540,7 +530,7 @@ impl MagicValidator { }); DomainRegistryManager::handle_registration_static( - self.config.rpc_url_or_default(), + self.config.rpc_url(), &validator_keypair, validator_info, ) @@ -553,7 +543,7 @@ impl MagicValidator { let validator_keypair = validator_authority(); DomainRegistryManager::handle_unregistration_static( - self.config.rpc_url_or_default(), + self.config.rpc_url(), &validator_keypair, ) .map_err(|err| { @@ -567,7 +557,7 @@ impl MagicValidator { const MIN_BALANCE_SOL: u64 = 5; let lamports = RpcClient::new_with_commitment( - self.config.rpc_url_or_default(), + self.config.rpc_url().to_owned(), CommitmentConfig::confirmed(), ) .get_balance(&self.identity) @@ -614,7 +604,7 @@ impl MagicValidator { .map(|co| co.claim_fees_frequency) { self.claim_fees_task - .start(frequency, self.config.rpc_url_or_default()); + .start(frequency, self.config.rpc_url().to_owned()); } self.slot_ticker = Some(init_slot_ticker( diff --git a/magicblock-config/src/config/cli.rs b/magicblock-config/src/config/cli.rs index d24ecd902..36774e71c 100644 --- a/magicblock-config/src/config/cli.rs +++ b/magicblock-config/src/config/cli.rs @@ -5,9 +5,7 @@ use serde::Serialize; use crate::{ config::LifecycleMode, - types::{ - remote::parse_remote_config, BindAddress, RemoteConfig, SerdeKeypair, - }, + types::{network::Remote, BindAddress, SerdeKeypair}, }; /// CLI Arguments mirroring the structure of ValidatorParams. @@ -18,13 +16,22 @@ pub struct CliParams { /// Path to the TOML configuration file. pub config: Option, - /// Remote Solana cluster connections. Can be specified multiple times. - /// Format: --remote : or --remote : - /// Examples: --remote rpc:devnet --remote websocket:devnet --remote grpc:http://localhost:50051 - /// Aliases: mainnet, devnet, local (resolved based on kind) - #[arg(long, short, value_parser = parse_remote_config)] - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub remote: Vec, + /// List of remote endpoints for syncing with the base chain. + /// Can be specified multiple times. + /// + /// SUPPORTED SCHEMES: http(s), ws(s), grpc(s) + /// + /// ALIASES: mainnet, devnet, testnet, localhost + /// + /// EXAMPLES: + /// - `--remote devnet` + /// - `--remote wss://devnet.solana.com` + /// - `--remote grpcs://grpc.example.com` + /// + /// DEFAULT: devnet (HTTP endpoint with auto-added WS endpoint) + #[arg(long)] + #[serde(skip_serializing_if = "Option::is_none")] + pub remotes: Option>, /// The application's operational mode. #[arg(long)] diff --git a/magicblock-config/src/consts.rs b/magicblock-config/src/consts.rs index f4ca8bcd3..4eb88d48b 100644 --- a/magicblock-config/src/consts.rs +++ b/magicblock-config/src/consts.rs @@ -20,15 +20,9 @@ pub const DEFAULT_BASE_FEE: u64 = 0; /// Default compute unit price in microlamports pub const DEFAULT_COMPUTE_UNIT_PRICE: u64 = 1_000_000; -// Remote URL Aliases - RPC -pub const RPC_MAINNET: &str = "https://api.mainnet-beta.solana.com/"; -pub const RPC_DEVNET: &str = "https://api.devnet.solana.com/"; -pub const RPC_LOCAL: &str = "http://localhost:8899/"; - -// Remote URL Aliases - WebSocket -pub const WS_MAINNET: &str = "wss://api.mainnet-beta.solana.com/"; -pub const WS_DEVNET: &str = "wss://api.devnet.solana.com/"; -pub const WS_LOCAL: &str = "ws://localhost:8899/"; +/// Remote URL Aliases - Mainnet, Testnet, Devnet, and Localhost +/// Solana mainnet-beta RPC endpoint +pub const MAINNET_URL: &str = "https://api.mainnet-beta.solana.com/"; /// Solana testnet RPC endpoint pub const TESTNET_URL: &str = "https://api.testnet.solana.com/"; diff --git a/magicblock-config/src/lib.rs b/magicblock-config/src/lib.rs index 81ca319be..5de7369b9 100644 --- a/magicblock-config/src/lib.rs +++ b/magicblock-config/src/lib.rs @@ -24,7 +24,7 @@ use crate::{ CommittorConfig, LedgerConfig, LoadableProgram, TaskSchedulerConfig, ValidatorConfig, }, - types::{resolve_url, BindAddress, RemoteConfig, RemoteKind}, + types::{network::Remote, BindAddress}, }; /// Top-level configuration, assembled from multiple sources. @@ -34,10 +34,9 @@ pub struct ValidatorParams { /// Path to the TOML configuration file (overrides CLI args). pub config: Option, - /// Array-based remote configurations for RPC, WebSocket, and gRPC. - /// Configured via [[remote]] sections in TOML (array-of-tables syntax). - #[serde(default, rename = "remote")] - pub remotes: Vec, + /// Remote endpoints for syncing with the base chain. + /// Can include HTTP (for JSON-RPC), WebSocket (for PubSub), and gRPC (for streaming) connections. + pub remotes: Vec, /// The application's operational mode. pub lifecycle: LifecycleMode, @@ -174,44 +173,6 @@ impl ValidatorParams { .filter(|r| matches!(r, Remote::Grpc(_))) .map(|r| r.url_str()) } - - /// Returns the first RPC remote URL as an Option. - pub fn rpc_url(&self) -> Option<&str> { - self.remotes - .iter() - .find(|r| r.kind == RemoteKind::Rpc) - .map(|r| r.url.as_str()) - } - - /// Returns an iterator over all WebSocket remote URLs. - pub fn websocket_urls(&self) -> impl Iterator + '_ { - self.remotes - .iter() - .filter(|r| r.kind == RemoteKind::Websocket) - .map(|r| r.url.as_str()) - } - - /// Returns an iterator over all gRPC remote URLs. - pub fn grpc_urls(&self) -> impl Iterator + '_ { - self.remotes - .iter() - .filter(|r| r.kind == RemoteKind::Grpc) - .map(|r| r.url.as_str()) - } - - pub fn has_subscription_url(&self) -> bool { - self.remotes.iter().any(|r| { - r.kind == RemoteKind::Websocket || r.kind == RemoteKind::Grpc - }) - } - - /// Returns the RPC URL, using DEFAULT_REMOTE as fallback if not - /// configured. - pub fn rpc_url_or_default(&self) -> String { - self.rpc_url().map(|s| s.to_string()).unwrap_or_else(|| { - resolve_url(RemoteKind::Rpc, consts::DEFAULT_REMOTE) - }) - } } impl Display for ValidatorParams { diff --git a/magicblock-config/src/tests.rs b/magicblock-config/src/tests.rs index a29a0afdb..c55cc0622 100644 --- a/magicblock-config/src/tests.rs +++ b/magicblock-config/src/tests.rs @@ -8,7 +8,7 @@ use tempfile::TempDir; use crate::{ config::{BlockSize, LifecycleMode}, consts::{self, DEFAULT_VALIDATOR_KEYPAIR}, - types::{remote::resolve_url, RemoteConfig, RemoteKind}, + types::network::Remote, ValidatorParams, }; @@ -63,8 +63,8 @@ fn test_defaults_are_sane() { // Verify key defaults used in production assert_eq!(config.validator.basefee, consts::DEFAULT_BASE_FEE); - // Remotes default to empty when not specified - assert_eq!(config.remotes.len(), 0); + // Remotes default to [devnet HTTP] + [devnet WS] (added by ensure_websocket) + assert_eq!(config.remotes.len(), 2); assert_eq!(config.listen.0.port(), 8899); assert_eq!(config.lifecycle, LifecycleMode::Ephemeral); @@ -359,10 +359,9 @@ fn test_example_config_full_coverage() { // 3. Core & Network // ======================================================================== assert_eq!(config.lifecycle, LifecycleMode::Ephemeral); - // Example config has one RPC remote with "devnet" alias resolved - assert_eq!(config.remotes.len(), 1); - assert_eq!(config.remotes[0].url, consts::RPC_DEVNET); - assert_eq!(config.remotes[0].kind, RemoteKind::Rpc); + // Example config has 3 remotes: devnet HTTP, devnet WebSocket, and Helius gRPC + assert_eq!(config.remotes.len(), 3); + assert_eq!(config.remotes[0].url_str(), consts::DEVNET_URL); assert_eq!(config.listen.0.port(), 8899); // Check that storage path is set (contains the expected folder name) assert!(config @@ -493,8 +492,8 @@ fn test_env_vars_full_coverage() { // Core assert_eq!(config.lifecycle, LifecycleMode::Replica); - // Remotes must be configured via TOML, not env vars - assert_eq!(config.remotes.len(), 0); + // Remotes default to devnet (HTTP) + devnet WebSocket (added by ensure_websocket) + assert_eq!(config.remotes.len(), 2); assert_eq!(config.storage.to_string_lossy(), "/tmp/env-test-storage"); assert_eq!(config.listen.0.port(), 9999); @@ -542,237 +541,46 @@ fn test_env_vars_full_coverage() { } // ============================================================================ -// 9. New Remote Config Parsing +// 9. Remote Type Parsing // ============================================================================ #[test] #[parallel] -fn test_parse_single_rpc_remote() { - let (_dir, config_path) = create_temp_config( - r#" - [[remote]] - kind = "rpc" - url = "http://localhost:8899" - "#, - ); - - let config = run_cli(vec![config_path.to_str().unwrap()]); - - assert_eq!(config.remotes.len(), 1); - assert_eq!(config.remotes[0].kind, RemoteKind::Rpc); - assert_eq!(config.remotes[0].url, "http://localhost:8899"); - assert_eq!(config.remotes[0].api_key, None); +fn test_parse_http_remote() { + let remote: Remote = "http://localhost:8899".parse().unwrap(); + assert!(matches!(remote, Remote::Http(_))); + assert_eq!(remote.url_str(), "http://localhost:8899/"); } #[test] #[parallel] -fn test_parse_rpc_remote_with_api_key() { - let (_dir, config_path) = create_temp_config( - r#" - [[remote]] - kind = "rpc" - url = "https://api.example.com" - api-key = "secret-key-123" - "#, - ); - - let config = run_cli(vec![config_path.to_str().unwrap()]); - - assert_eq!(config.remotes.len(), 1); - assert_eq!(config.remotes[0].kind, RemoteKind::Rpc); - assert_eq!(config.remotes[0].url, "https://api.example.com"); - assert_eq!( - config.remotes[0].api_key, - Some("secret-key-123".to_string()) - ); +fn test_parse_websocket_remote() { + let remote: Remote = "ws://localhost:8900".parse().unwrap(); + assert!(matches!(remote, Remote::Websocket(_))); } #[test] #[parallel] -fn test_parse_multiple_remotes_mixed_kinds() { - let (_dir, config_path) = create_temp_config( - r#" - [[remote]] - kind = "rpc" - url = "http://localhost:8899" - - [[remote]] - kind = "websocket" - url = "wss://mainnet-beta.solana.com" - - [[remote]] - kind = "websocket" - url = "wss://backup-node.example.com" - - [[remote]] - kind = "grpc" - url = "http://grpc.example.com:50051" - api-key = "grpc-secret" - "#, - ); - - let config = run_cli(vec![config_path.to_str().unwrap()]); - - assert_eq!(config.remotes.len(), 4); - - // First: RPC - assert_eq!(config.remotes[0].kind, RemoteKind::Rpc); - assert_eq!(config.remotes[0].url, "http://localhost:8899"); - - // Second: WebSocket - assert_eq!(config.remotes[1].kind, RemoteKind::Websocket); - assert_eq!(config.remotes[1].url, "wss://mainnet-beta.solana.com"); - assert_eq!(config.remotes[1].api_key, None); - - // Third: WebSocket (multiple remotes of the same kind allowed) - assert_eq!(config.remotes[2].kind, RemoteKind::Websocket); - assert_eq!(config.remotes[2].url, "wss://backup-node.example.com"); - - // Fourth: gRPC - assert_eq!(config.remotes[3].kind, RemoteKind::Grpc); - assert_eq!(config.remotes[3].url, "http://grpc.example.com:50051"); - assert_eq!(config.remotes[3].api_key, Some("grpc-secret".to_string())); +fn test_parse_grpc_remote_converts_scheme() { + let remote: Remote = "grpc://localhost:50051/".parse().unwrap(); + assert!(matches!(remote, Remote::Grpc(_))); + // Scheme should be converted to http + assert_eq!(remote.url_str(), "http://localhost:50051/"); } #[test] #[parallel] -fn test_parse_remotes_empty_when_not_provided() { - let (_dir, config_path) = create_temp_config( - r#" - lifecycle = "ephemeral" - "#, - ); - - let config = run_cli(vec![config_path.to_str().unwrap()]); - - assert_eq!(config.remotes.len(), 0); -} - -#[test] -#[parallel] -fn test_deserialization_resolves_aliases() { - // Verify that aliases are resolved during deserialization, - // not just in resolved_url() method - let (_dir, config_path) = create_temp_config( - r#" - [[remote]] - kind = "rpc" - url = "devnet" - - [[remote]] - kind = "websocket" - url = "mainnet" - "#, - ); - - let config = run_cli(vec![config_path.to_str().unwrap()]); - - assert_eq!(config.remotes.len(), 2); - - // RPC remote should have devnet alias resolved to actual URL - assert_eq!(config.remotes[0].kind, RemoteKind::Rpc); - assert_eq!(config.remotes[0].url, consts::RPC_DEVNET); - - // WebSocket remote should have mainnet alias resolved to actual URL - assert_eq!(config.remotes[1].kind, RemoteKind::Websocket); - assert_eq!(config.remotes[1].url, consts::WS_MAINNET); -} - -#[test] -#[parallel] -fn test_remote_config_parse_url_method() { - let remote = RemoteConfig { - kind: RemoteKind::Rpc, - url: "https://api.example.com".to_string(), - api_key: None, - }; - - let parsed_url = remote.parse_url(); - assert!(parsed_url.is_ok()); - let url = parsed_url.unwrap(); - assert_eq!(url.scheme(), "https"); - assert_eq!(url.host_str(), Some("api.example.com")); -} - -#[test] -#[parallel] -fn test_remote_config_invalid_url() { - let remote = RemoteConfig { - kind: RemoteKind::Rpc, - url: "not a valid url".to_string(), - api_key: None, - }; - - let parsed_url = remote.parse_url(); - assert!(parsed_url.is_err()); -} - -#[test] -#[parallel] -fn test_rpc_alias_resolution() { - assert_eq!(resolve_url(RemoteKind::Rpc, "mainnet"), consts::RPC_MAINNET); - assert_eq!(resolve_url(RemoteKind::Rpc, "devnet"), consts::RPC_DEVNET); - assert_eq!(resolve_url(RemoteKind::Rpc, "local"), consts::RPC_LOCAL); -} - -#[test] -#[parallel] -fn test_websocket_alias_resolution() { - assert_eq!( - resolve_url(RemoteKind::Websocket, "mainnet"), - consts::WS_MAINNET - ); - assert_eq!( - resolve_url(RemoteKind::Websocket, "devnet"), - consts::WS_DEVNET - ); - assert_eq!( - resolve_url(RemoteKind::Websocket, "local"), - consts::WS_LOCAL - ); -} - -#[test] -#[parallel] -fn test_alias_resolution_same_alias_different_kinds() { - let rpc_resolved = resolve_url(RemoteKind::Rpc, "mainnet"); - let ws_resolved = resolve_url(RemoteKind::Websocket, "mainnet"); - - assert_eq!(rpc_resolved, consts::RPC_MAINNET); - assert_eq!(ws_resolved, consts::WS_MAINNET); - // They should be different - assert_ne!(rpc_resolved, ws_resolved); -} - -#[test] -#[parallel] -fn test_full_url_not_treated_as_alias() { - assert_eq!( - resolve_url(RemoteKind::Rpc, "https://custom-node.example.com"), - "https://custom-node.example.com" - ); - assert_eq!( - resolve_url(RemoteKind::Websocket, "wss://custom-node.example.com"), - "wss://custom-node.example.com" - ); +fn test_parse_alias() { + let remote: Remote = "devnet".parse().unwrap(); + assert!(matches!(remote, Remote::Http(_))); + assert_eq!(remote.url_str(), consts::DEVNET_URL); } #[test] #[parallel] -fn test_parse_url_with_alias() { - let resolved = resolve_url(RemoteKind::Rpc, "devnet"); - let remote = RemoteConfig { - kind: RemoteKind::Rpc, - url: resolved, - api_key: None, - }; - - let parsed = remote.parse_url(); - assert!(parsed.is_ok()); - let url = parsed.unwrap(); - - // Extract expected host from the canonical constant - let expected_url = url::Url::parse(consts::RPC_DEVNET) - .expect("Failed to parse RPC_DEVNET constant"); - assert_eq!(url.host_str(), expected_url.host_str()); +fn test_to_websocket_from_http() { + let remote: Remote = "http://localhost:8899".parse().unwrap(); + let ws_remote = remote.to_websocket().unwrap(); + assert!(matches!(ws_remote, Remote::Websocket(_))); + assert_eq!(ws_remote.url_str(), "ws://localhost:8900/"); } diff --git a/magicblock-config/src/types/mod.rs b/magicblock-config/src/types/mod.rs index 49e7c91f5..bef4cce81 100644 --- a/magicblock-config/src/types/mod.rs +++ b/magicblock-config/src/types/mod.rs @@ -1,12 +1,12 @@ use std::{fmt::Display, path::PathBuf}; pub mod crypto; -pub mod remote; +pub mod network; // Re-export types for easy access pub use crypto::{SerdeKeypair, SerdePubkey}; use derive_more::{Deref, FromStr}; -pub use remote::{resolve_url, BindAddress, RemoteConfig, RemoteKind}; +pub use network::BindAddress; use serde_with::{DeserializeFromStr, SerializeDisplay}; use crate::consts; diff --git a/magicblock-config/src/types/network.rs b/magicblock-config/src/types/network.rs new file mode 100644 index 000000000..68943dad4 --- /dev/null +++ b/magicblock-config/src/types/network.rs @@ -0,0 +1,143 @@ +use std::{net::SocketAddr, str::FromStr}; + +use derive_more::{Deref, Display, FromStr}; +use serde::{Deserialize, Serialize}; +use serde_with::{DeserializeFromStr, SerializeDisplay}; +use url::Url; + +use crate::consts; + +/// A network bind address that can be parsed from a string like "0.0.0.0:8080". +#[derive( + Clone, Copy, Debug, Deserialize, Serialize, FromStr, Display, Deref, +)] +#[serde(transparent)] +pub struct BindAddress(pub SocketAddr); + +impl Default for BindAddress { + fn default() -> Self { + consts::DEFAULT_RPC_ADDR.parse().unwrap() + } +} + +impl BindAddress { + fn as_connect_addr(&self) -> SocketAddr { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + match self.0.ip() { + IpAddr::V4(ip) if ip.is_unspecified() => { + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), self.0.port()) + } + IpAddr::V6(ip) if ip.is_unspecified() => { + SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), self.0.port()) + } + _ => self.0, + } + } + + pub fn http(&self) -> String { + format!("http://{}", self.as_connect_addr()) + } + + pub fn websocket(&self) -> String { + format!("ws://{}", self.as_connect_addr()) + } +} + +/// A remote endpoint for syncing with the base chain. +/// +/// Supported types: +/// - **Http**: JSON-RPC HTTP endpoint (scheme: `http` or `https`) +/// - **Websocket**: WebSocket endpoint for PubSub subscriptions (scheme: `ws` or `wss`) +/// - **Grpc**: gRPC endpoint for streaming (schemes `grpc`/`grpcs` are converted to `http`/`https`) +#[derive(Clone, DeserializeFromStr, SerializeDisplay, Display, Debug)] +pub enum Remote { + Http(AliasedUrl), + Websocket(AliasedUrl), + Grpc(AliasedUrl), +} + +impl FromStr for Remote { + type Err = url::ParseError; + + fn from_str(s: &str) -> Result { + // Handle non-standard schemes by detecting them before parsing + let mut s = s.to_owned(); + let is_grpc = s.starts_with("grpc"); + if is_grpc { + // SAFETY: + // We made sure that "grpc" is the prefix and we are not violating Unicode invariants + unsafe { s.as_bytes_mut()[0..4].copy_from_slice(b"http") }; + } + + let parsed = AliasedUrl::from_str(&s)?; + let remote = match parsed.0.scheme() { + _ if is_grpc => Self::Grpc(parsed), + "http" | "https" => Self::Http(parsed), + "ws" | "wss" => Self::Websocket(parsed), + _ => return Err(url::ParseError::InvalidDomainCharacter), + }; + Ok(remote) + } +} + +impl Remote { + /// Returns the URL as a string reference. + pub fn url_str(&self) -> &str { + match self { + Self::Http(u) => u.as_str(), + Self::Websocket(u) => u.as_str(), + Self::Grpc(u) => u.as_str(), + } + } + + /// Converts an HTTP remote to a WebSocket remote by deriving the appropriate WebSocket URL. + pub(crate) fn to_websocket(&self) -> Option { + let mut url = match self { + Self::Websocket(_) => return Some(self.clone()), + Self::Grpc(_) => return None, + Self::Http(u) => u.0.clone(), + }; + let _ = if url.scheme() == "http" { + url.set_scheme("ws") + } else { + url.set_scheme("wss") + }; + if let Some(port) = url.port() { + // As per solana convention websocket port is one greater than http + let _ = url.set_port(Some(port + 1)); + } + Some(Self::Websocket(AliasedUrl(url))) + } +} + +/// A URL that can be aliased with shortcuts like "mainnet". +/// +/// Aliases are resolved during parsing and replaced with their full URLs. +#[derive( + Clone, Debug, Deserialize, SerializeDisplay, Display, PartialEq, Deref, +)] +pub struct AliasedUrl(pub Url); + +impl AliasedUrl { + /// Returns the URL as a string reference. + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl FromStr for AliasedUrl { + type Err = url::ParseError; + + /// Parses a string into an AliasedUrl, resolving known aliases to their full URLs. + fn from_str(s: &str) -> Result { + let url_str = match s { + "mainnet" => consts::MAINNET_URL, + "devnet" => consts::DEVNET_URL, + "testnet" => consts::TESTNET_URL, + "localhost" | "dev" => consts::LOCALHOST_URL, + custom => custom, + }; + Url::parse(url_str).map(Self) + } +} diff --git a/magicblock-config/src/types/remote.rs b/magicblock-config/src/types/remote.rs deleted file mode 100644 index 99c8d4544..000000000 --- a/magicblock-config/src/types/remote.rs +++ /dev/null @@ -1,491 +0,0 @@ -use std::net::SocketAddr; - -use derive_more::{Deref, Display, FromStr}; -use serde::{ - de::{self, MapAccess, Visitor}, - Deserialize, Deserializer, Serialize, -}; -use url::Url; - -use crate::consts; - -/// A network bind address that can be parsed from a string like "0.0.0.0:8080". -#[derive( - Clone, Copy, Debug, Deserialize, Serialize, FromStr, Display, Deref, -)] -#[serde(transparent)] -pub struct BindAddress(pub SocketAddr); - -impl Default for BindAddress { - fn default() -> Self { - consts::DEFAULT_RPC_ADDR.parse().unwrap() - } -} - -impl BindAddress { - fn as_connect_addr(&self) -> SocketAddr { - use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; - - match self.0.ip() { - IpAddr::V4(ip) if ip.is_unspecified() => { - SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), self.0.port()) - } - IpAddr::V6(ip) if ip.is_unspecified() => { - SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), self.0.port()) - } - _ => self.0, - } - } - - pub fn http(&self) -> String { - format!("http://{}", self.as_connect_addr()) - } - - pub fn websocket(&self) -> String { - format!("ws://{}", self.as_connect_addr()) - } -} - -/// The kind of remote connection. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum RemoteKind { - /// JSON-RPC HTTP connection. - Rpc, - /// WebSocket connection used for subscriptions. - Websocket, - /// gRPC connection used for subscriptions. - Grpc, -} - -/// Configuration for a single remote connection. -/// Aliases in the URL field are automatically resolved during deserialization. -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct RemoteConfig { - /// The kind of remote connection (rpc, websocket, grpc). - pub kind: RemoteKind, - - /// The resolved URL for this remote connection. - /// If an alias was used in the config, it is automatically expanded during deserialization. - pub url: String, - - /// Optional API key for authentication. - #[serde(skip_serializing_if = "Option::is_none")] - pub api_key: Option, -} - -impl<'de> Deserialize<'de> for RemoteConfig { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct RemoteConfigVisitor; - - impl<'de> Visitor<'de> for RemoteConfigVisitor { - type Value = RemoteConfig; - - fn expecting( - &self, - formatter: &mut std::fmt::Formatter, - ) -> std::fmt::Result { - formatter.write_str("a remote configuration object") - } - - fn visit_map(self, mut map: A) -> Result - where - A: MapAccess<'de>, - { - let mut kind: Option = None; - let mut url: Option = None; - let mut api_key: Option = None; - - while let Some(key) = map.next_key::()? { - match key.as_str() { - "kind" => kind = Some(map.next_value()?), - "url" => url = Some(map.next_value()?), - "api-key" => api_key = map.next_value()?, - _ => { - // Ignore unknown fields - let the value be consumed - let _ = map.next_value::()?; - } - } - } - - let kind = - kind.ok_or_else(|| de::Error::missing_field("kind"))?; - let url = url.ok_or_else(|| de::Error::missing_field("url"))?; - - // Resolve the URL alias based on the kind - let resolved_url = resolve_url(kind, &url); - - Ok(RemoteConfig { - kind, - url: resolved_url, - api_key, - }) - } - } - - deserializer.deserialize_map(RemoteConfigVisitor) - } -} - -impl RemoteConfig { - /// Parses the resolved URL and returns a valid `Url` object. - pub fn parse_url(&self) -> Result { - Url::parse(&self.url) - } -} - -/// Resolves aliases to a URL and passes through custom URLs unchanged. -pub fn resolve_url(kind: RemoteKind, url: &str) -> String { - match kind { - RemoteKind::Rpc => match url { - "mainnet" => consts::RPC_MAINNET.to_string(), - "devnet" => consts::RPC_DEVNET.to_string(), - "local" => consts::RPC_LOCAL.to_string(), - _ => url.to_string(), - }, - RemoteKind::Websocket => match url { - "mainnet" => consts::WS_MAINNET.to_string(), - "devnet" => consts::WS_DEVNET.to_string(), - "local" => consts::WS_LOCAL.to_string(), - _ => url.to_string(), - }, - RemoteKind::Grpc => url.to_string(), - } -} - -/// Parses CLI remote config argument in format: kind:url[?api-key=value] -/// Example: rpc:devnet, websocket:https://api.devnet.solana.com -/// Important: The URL can contain colons (https://), so we match 'kind:' -/// only if it's a valid kind at the start. -pub fn parse_remote_config(s: &str) -> Result { - // Find the kind by looking for a valid kind followed by a colon - let kinds = ["rpc", "websocket", "grpc"]; - let kind_and_rest = kinds - .iter() - .find_map(|k| { - let prefix = format!("{}:", k); - if s.starts_with(&prefix) { - Some((*k, &s[prefix.len()..])) - } else { - None - } - }) - .ok_or_else(|| { - "Remote format must start with 'kind:url' where kind is \ - one of: rpc, websocket, grpc. Example: 'rpc:devnet'" - .to_string() - })?; - - let kind = match kind_and_rest.0 { - "rpc" => RemoteKind::Rpc, - "websocket" => RemoteKind::Websocket, - "grpc" => RemoteKind::Grpc, - // SAFETY: we already excluded invalid kinds above - _ => unreachable!(), - }; - - let rest = kind_and_rest.1; - let (url, api_key) = if let Some((url, query)) = rest.split_once('?') { - // Parse query parameters to extract api-key regardless of order or other - // parameters. Split on '&' to get individual key=value pairs. - let api_key = query.split('&').find_map(|pair| { - pair.split_once('=').and_then(|(k, v)| { - if k == "api-key" { - Some(v.to_string()) - } else { - None - } - }) - }); - (url.to_string(), api_key) - } else { - (rest.to_string(), None) - }; - - // Validate that URL is not empty - if url.trim().is_empty() { - return Err( - "URL cannot be empty. Provide a valid URL or alias (mainnet, \ - devnet, local)" - .to_string(), - ); - } - - // Resolve the URL alias based on the kind - let resolved_url = resolve_url(kind, &url); - // Validate URL format for non-alias URLs - if !["mainnet", "devnet", "local"].contains(&url.as_str()) - && Url::parse(&resolved_url).is_err() - { - return Err(format!( - "Invalid URL format: '{}'. Expected a valid URL like 'http://localhost:8899'", - resolved_url - )); - } - - Ok(RemoteConfig { - kind, - url: resolved_url, - api_key, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_rpc_without_api_key() { - let result = parse_remote_config("rpc:https://api.devnet.solana.com"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.kind, RemoteKind::Rpc); - assert_eq!(config.url, "https://api.devnet.solana.com"); - assert_eq!(config.api_key, None); - } - - #[test] - fn test_parse_rpc_with_api_key() { - let result = parse_remote_config( - "rpc:https://api.devnet.solana.com?api-key=secret123", - ); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.kind, RemoteKind::Rpc); - assert_eq!(config.url, "https://api.devnet.solana.com"); - assert_eq!(config.api_key, Some("secret123".to_string())); - } - - #[test] - fn test_parse_websocket_without_api_key() { - let result = - parse_remote_config("websocket:wss://api.devnet.solana.com"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.kind, RemoteKind::Websocket); - assert_eq!(config.url, "wss://api.devnet.solana.com"); - assert_eq!(config.api_key, None); - } - - #[test] - fn test_parse_websocket_with_api_key() { - let result = parse_remote_config( - "websocket:wss://api.devnet.solana.com?api-key=mykey", - ); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.kind, RemoteKind::Websocket); - assert_eq!(config.url, "wss://api.devnet.solana.com"); - assert_eq!(config.api_key, Some("mykey".to_string())); - } - - #[test] - fn test_parse_grpc_without_api_key() { - let result = parse_remote_config("grpc:http://localhost:50051"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.kind, RemoteKind::Grpc); - assert_eq!(config.url, "http://localhost:50051"); - assert_eq!(config.api_key, None); - } - - #[test] - fn test_parse_grpc_with_api_key() { - let result = - parse_remote_config("grpc:http://localhost:50051?api-key=xyz"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.kind, RemoteKind::Grpc); - assert_eq!(config.url, "http://localhost:50051"); - assert_eq!(config.api_key, Some("xyz".to_string())); - } - - #[test] - fn test_parse_missing_kind() { - let result = parse_remote_config("https://api.devnet.solana.com"); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("Remote format must start with")); - } - - #[test] - fn test_parse_invalid_kind() { - let result = parse_remote_config("http:https://api.devnet.solana.com"); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("Remote format must start with")); - } - - #[test] - fn test_parse_empty_url() { - let result = parse_remote_config("rpc:"); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("URL cannot be empty")); - } - - #[test] - fn test_parse_api_key_with_special_chars() { - let result = parse_remote_config( - "rpc:http://localhost:8899?api-key=abc_123-xyz", - ); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.api_key, Some("abc_123-xyz".to_string())); - } - - #[test] - fn test_parse_url_with_port() { - let result = parse_remote_config("rpc:http://localhost:8899"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.url, "http://localhost:8899"); - } - - #[test] - fn test_parse_url_with_path() { - let result = parse_remote_config("rpc:http://localhost:8899/v1"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.url, "http://localhost:8899/v1"); - } - - // ================================================================ - // Aliases - // ================================================================ - - #[test] - fn test_parse_rpc_mainnet_alias() { - let result = parse_remote_config("rpc:mainnet"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.kind, RemoteKind::Rpc); - assert_eq!(config.url, consts::RPC_MAINNET); - } - - #[test] - fn test_parse_rpc_devnet_alias() { - let result = parse_remote_config("rpc:devnet"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.kind, RemoteKind::Rpc); - assert_eq!(config.url, consts::RPC_DEVNET); - } - - #[test] - fn test_parse_rpc_local_alias() { - let result = parse_remote_config("rpc:local"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.kind, RemoteKind::Rpc); - assert_eq!(config.url, consts::RPC_LOCAL); - } - - #[test] - fn test_parse_websocket_mainnet_alias() { - let result = parse_remote_config("websocket:mainnet"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.kind, RemoteKind::Websocket); - assert_eq!(config.url, consts::WS_MAINNET); - } - - #[test] - fn test_parse_websocket_devnet_alias() { - let result = parse_remote_config("websocket:devnet"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.kind, RemoteKind::Websocket); - assert_eq!(config.url, consts::WS_DEVNET); - } - - #[test] - fn test_parse_websocket_local_alias() { - let result = parse_remote_config("websocket:local"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.kind, RemoteKind::Websocket); - assert_eq!(config.url, consts::WS_LOCAL); - } - - #[test] - fn test_parse_rpc_alias_with_api_key() { - let result = parse_remote_config("rpc:mainnet?api-key=secret"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.kind, RemoteKind::Rpc); - assert_eq!(config.url, consts::RPC_MAINNET); - assert_eq!(config.api_key, Some("secret".to_string())); - } - - #[test] - fn test_parse_websocket_alias_with_api_key() { - let result = parse_remote_config("websocket:devnet?api-key=mytoken"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.kind, RemoteKind::Websocket); - assert_eq!(config.url, consts::WS_DEVNET); - assert_eq!(config.api_key, Some("mytoken".to_string())); - } - - #[test] - fn test_parse_different_kinds_same_alias() { - // Same alias "mainnet" should resolve to different URLs based on kind - let rpc = parse_remote_config("rpc:mainnet").unwrap(); - let ws = parse_remote_config("websocket:mainnet").unwrap(); - - assert_eq!(rpc.kind, RemoteKind::Rpc); - assert_eq!(ws.kind, RemoteKind::Websocket); - assert_eq!(rpc.url, consts::RPC_MAINNET); - assert_eq!(ws.url, consts::WS_MAINNET); - assert_ne!(rpc.url, ws.url); - } - - #[test] - fn test_parse_api_key_with_other_parameters() { - // Test api-key is correctly extracted when other query parameters - // are present - let result = parse_remote_config( - "rpc:http://localhost:8899?timeout=30&api-key=secret123&retry=3", - ); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.api_key, Some("secret123".to_string())); - } - - #[test] - fn test_parse_api_key_first_parameter() { - // Test api-key is correctly extracted when it's the first parameter - let result = parse_remote_config( - "rpc:http://localhost:8899?api-key=mykey&timeout=30", - ); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.api_key, Some("mykey".to_string())); - } - - #[test] - fn test_parse_api_key_last_parameter() { - // Test api-key is correctly extracted when it's the last parameter - let result = parse_remote_config( - "rpc:http://localhost:8899?timeout=30&api-key=lastkey", - ); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.api_key, Some("lastkey".to_string())); - } - - #[test] - fn test_parse_other_parameters_without_api_key() { - // Test that other query parameters don't break when api-key is absent - let result = - parse_remote_config("rpc:http://localhost:8899?timeout=30&retry=3"); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.api_key, None); - } -} diff --git a/test-integration/Cargo.lock b/test-integration/Cargo.lock index 154e2a6a1..116acb551 100644 --- a/test-integration/Cargo.lock +++ b/test-integration/Cargo.lock @@ -2049,10 +2049,10 @@ dependencies = [ [[package]] name = "guinea" -version = "0.4.2" +version = "0.5.0" dependencies = [ "bincode", - "magicblock-magic-program-api 0.4.2", + "magicblock-magic-program-api 0.5.0", "serde", "solana-program", ] @@ -3009,7 +3009,7 @@ dependencies = [ [[package]] name = "magicblock-account-cloner" -version = "0.4.2" +version = "0.5.0" dependencies = [ "async-trait", "bincode", @@ -3020,7 +3020,7 @@ dependencies = [ "magicblock-config", "magicblock-core", "magicblock-ledger", - "magicblock-magic-program-api 0.4.2", + "magicblock-magic-program-api 0.5.0", "magicblock-program", "magicblock-rpc-client", "rand 0.9.1", @@ -3041,7 +3041,7 @@ dependencies = [ [[package]] name = "magicblock-accounts" -version = "0.4.2" +version = "0.5.0" dependencies = [ "async-trait", "log", @@ -3063,7 +3063,7 @@ dependencies = [ [[package]] name = "magicblock-accounts-db" -version = "0.4.2" +version = "0.5.0" dependencies = [ "lmdb-rkv", "log", @@ -3079,7 +3079,7 @@ dependencies = [ [[package]] name = "magicblock-aperture" -version = "0.4.2" +version = "0.5.0" dependencies = [ "arc-swap", "base64 0.21.7", @@ -3124,7 +3124,7 @@ dependencies = [ [[package]] name = "magicblock-api" -version = "0.4.2" +version = "0.5.0" dependencies = [ "anyhow", "borsh 1.5.7", @@ -3140,7 +3140,7 @@ dependencies = [ "magicblock-config", "magicblock-core", "magicblock-ledger", - "magicblock-magic-program-api 0.4.2", + "magicblock-magic-program-api 0.5.0", "magicblock-metrics", "magicblock-processor", "magicblock-program", @@ -3179,7 +3179,7 @@ dependencies = [ [[package]] name = "magicblock-chainlink" -version = "0.4.2" +version = "0.5.0" dependencies = [ "arc-swap", "async-trait", @@ -3191,7 +3191,7 @@ dependencies = [ "magicblock-config", "magicblock-core", "magicblock-delegation-program", - "magicblock-magic-program-api 0.4.2", + "magicblock-magic-program-api 0.5.0", "magicblock-metrics", "solana-account", "solana-account-decoder", @@ -3226,7 +3226,7 @@ dependencies = [ [[package]] name = "magicblock-committor-program" -version = "0.4.2" +version = "0.5.0" dependencies = [ "borsh 1.5.7", "paste", @@ -3238,7 +3238,7 @@ dependencies = [ [[package]] name = "magicblock-committor-service" -version = "0.4.2" +version = "0.5.0" dependencies = [ "async-trait", "base64 0.21.7", @@ -3281,7 +3281,7 @@ dependencies = [ [[package]] name = "magicblock-config" -version = "0.4.2" +version = "0.5.0" dependencies = [ "clap", "derive_more", @@ -3299,10 +3299,10 @@ dependencies = [ [[package]] name = "magicblock-core" -version = "0.4.2" +version = "0.5.0" dependencies = [ "flume", - "magicblock-magic-program-api 0.4.2", + "magicblock-magic-program-api 0.5.0", "solana-account", "solana-account-decoder", "solana-hash", @@ -3337,7 +3337,7 @@ dependencies = [ [[package]] name = "magicblock-ledger" -version = "0.4.2" +version = "0.5.0" dependencies = [ "arc-swap", "bincode", @@ -3387,7 +3387,7 @@ dependencies = [ [[package]] name = "magicblock-magic-program-api" -version = "0.4.2" +version = "0.5.0" dependencies = [ "bincode", "serde", @@ -3396,7 +3396,7 @@ dependencies = [ [[package]] name = "magicblock-metrics" -version = "0.4.2" +version = "0.5.0" dependencies = [ "http-body-util", "hyper 1.6.0", @@ -3410,7 +3410,7 @@ dependencies = [ [[package]] name = "magicblock-processor" -version = "0.4.2" +version = "0.5.0" dependencies = [ "bincode", "log", @@ -3444,12 +3444,12 @@ dependencies = [ [[package]] name = "magicblock-program" -version = "0.4.2" +version = "0.5.0" dependencies = [ "bincode", "lazy_static", "magicblock-core", - "magicblock-magic-program-api 0.4.2", + "magicblock-magic-program-api 0.5.0", "num-derive", "num-traits", "parking_lot", @@ -3476,7 +3476,7 @@ dependencies = [ [[package]] name = "magicblock-rpc-client" -version = "0.4.2" +version = "0.5.0" dependencies = [ "log", "solana-account", @@ -3497,7 +3497,7 @@ dependencies = [ [[package]] name = "magicblock-table-mania" -version = "0.4.2" +version = "0.5.0" dependencies = [ "ed25519-dalek", "log", @@ -3523,7 +3523,7 @@ dependencies = [ [[package]] name = "magicblock-task-scheduler" -version = "0.4.2" +version = "0.5.0" dependencies = [ "bincode", "chrono", @@ -3549,7 +3549,7 @@ dependencies = [ [[package]] name = "magicblock-validator-admin" -version = "0.4.2" +version = "0.5.0" dependencies = [ "log", "magicblock-delegation-program", @@ -3566,7 +3566,7 @@ dependencies = [ [[package]] name = "magicblock-version" -version = "0.4.2" +version = "0.5.0" dependencies = [ "git-version", "rustc_version", @@ -4402,7 +4402,7 @@ dependencies = [ "bincode", "borsh 1.5.7", "ephemeral-rollups-sdk", - "magicblock-magic-program-api 0.4.2", + "magicblock-magic-program-api 0.5.0", "serde", "solana-program", ] @@ -4426,7 +4426,7 @@ dependencies = [ "borsh 1.5.7", "ephemeral-rollups-sdk", "magicblock-delegation-program", - "magicblock-magic-program-api 0.4.2", + "magicblock-magic-program-api 0.5.0", "solana-program", ] @@ -5276,7 +5276,7 @@ dependencies = [ "integration-test-tools", "log", "magicblock-core", - "magicblock-magic-program-api 0.4.2", + "magicblock-magic-program-api 0.5.0", "program-schedulecommit", "schedulecommit-client", "solana-program", @@ -5292,7 +5292,7 @@ version = "0.0.0" dependencies = [ "integration-test-tools", "magicblock-core", - "magicblock-magic-program-api 0.4.2", + "magicblock-magic-program-api 0.5.0", "program-schedulecommit", "program-schedulecommit-security", "schedulecommit-client", @@ -7972,7 +7972,7 @@ dependencies = [ [[package]] name = "solana-storage-proto" -version = "0.4.2" +version = "0.5.0" dependencies = [ "bincode", "bs58", @@ -9439,7 +9439,7 @@ dependencies = [ [[package]] name = "test-kit" -version = "0.4.2" +version = "0.5.0" dependencies = [ "env_logger 0.11.8", "guinea", diff --git a/test-integration/configs/api-conf.ephem.toml b/test-integration/configs/api-conf.ephem.toml index 00480a5f0..6fe93ed65 100644 --- a/test-integration/configs/api-conf.ephem.toml +++ b/test-integration/configs/api-conf.ephem.toml @@ -2,13 +2,7 @@ lifecycle = "ephemeral" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:8899" -[[remote]] -kind = "rpc" -url = "http://127.0.0.1:7799" - -[[remote]] -kind = "websocket" -url = "ws://127.0.0.1:7800" +remotes = ["http://127.0.0.1:7799", "ws://127.0.0.1:7800"] [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/chainlink-conf.devnet.toml b/test-integration/configs/chainlink-conf.devnet.toml index ebac4d7af..eed2730f5 100644 --- a/test-integration/configs/chainlink-conf.devnet.toml +++ b/test-integration/configs/chainlink-conf.devnet.toml @@ -2,13 +2,7 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -[[remote]] -kind = "rpc" -url = "devnet" - -[[remote]] -kind = "websocket" -url = "devnet" +remotes = ["devnet"] [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/claim-fees-test.toml b/test-integration/configs/claim-fees-test.toml index a6d2756c4..12e0445aa 100644 --- a/test-integration/configs/claim-fees-test.toml +++ b/test-integration/configs/claim-fees-test.toml @@ -2,13 +2,7 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -[[remote]] -kind = "rpc" -url = "devnet" - -[[remote]] -kind = "websocket" -url = "devnet" +remotes = ["devnet"] [accountsdb] database-size = 1048576000 diff --git a/test-integration/configs/cloning-conf.devnet.toml b/test-integration/configs/cloning-conf.devnet.toml index 5f7203788..a3e86ba80 100644 --- a/test-integration/configs/cloning-conf.devnet.toml +++ b/test-integration/configs/cloning-conf.devnet.toml @@ -2,13 +2,7 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -[[remote]] -kind = "rpc" -url = "devnet" - -[[remote]] -kind = "websocket" -url = "devnet" +remotes = ["devnet"] [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/cloning-conf.ephem.toml b/test-integration/configs/cloning-conf.ephem.toml index ca3ca0842..e4537421a 100644 --- a/test-integration/configs/cloning-conf.ephem.toml +++ b/test-integration/configs/cloning-conf.ephem.toml @@ -2,13 +2,7 @@ lifecycle = "ephemeral" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:8899" -[[remote]] -kind = "rpc" -url = "http://0.0.0.0:7799" - -[[remote]] -kind = "websocket" -url = "ws://0.0.0.0:7800" +remotes = ["http://0.0.0.0:7799", "ws://0.0.0.0:7800"] [chainlink] max-monitored-accounts = 3 diff --git a/test-integration/configs/committor-conf.devnet.toml b/test-integration/configs/committor-conf.devnet.toml index 638f29fe9..e58bec684 100644 --- a/test-integration/configs/committor-conf.devnet.toml +++ b/test-integration/configs/committor-conf.devnet.toml @@ -2,13 +2,7 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -[[remote]] -kind = "rpc" -url = "devnet" - -[[remote]] -kind = "websocket" -url = "devnet" +remotes = ["devnet"] [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/config-conf.devnet.toml b/test-integration/configs/config-conf.devnet.toml index 36157ac24..6a18de264 100644 --- a/test-integration/configs/config-conf.devnet.toml +++ b/test-integration/configs/config-conf.devnet.toml @@ -2,13 +2,7 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -[[remote]] -kind = "rpc" -url = "devnet" - -[[remote]] -kind = "websocket" -url = "devnet" +remotes = ["devnet"] [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/restore-ledger-conf.devnet.toml b/test-integration/configs/restore-ledger-conf.devnet.toml index 716cc64a0..66ab8c30d 100644 --- a/test-integration/configs/restore-ledger-conf.devnet.toml +++ b/test-integration/configs/restore-ledger-conf.devnet.toml @@ -2,13 +2,7 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -[[remote]] -kind = "rpc" -url = "devnet" - -[[remote]] -kind = "websocket" -url = "devnet" +remotes = ["devnet"] [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/schedule-task.devnet.toml b/test-integration/configs/schedule-task.devnet.toml index 2c7461b6d..167bbdba6 100644 --- a/test-integration/configs/schedule-task.devnet.toml +++ b/test-integration/configs/schedule-task.devnet.toml @@ -2,13 +2,7 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -[[remote]] -kind = "rpc" -url = "devnet" - -[[remote]] -kind = "websocket" -url = "devnet" +remotes = ["devnet"] [ledger] block-time = "50ms" diff --git a/test-integration/configs/schedule-task.ephem.toml b/test-integration/configs/schedule-task.ephem.toml index 85d819d24..e6d7dd573 100644 --- a/test-integration/configs/schedule-task.ephem.toml +++ b/test-integration/configs/schedule-task.ephem.toml @@ -2,13 +2,7 @@ lifecycle = "ephemeral" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:8899" -[[remote]] -kind = "rpc" -url = "http://0.0.0.0:7799" - -[[remote]] -kind = "websocket" -url = "ws://0.0.0.0:7800" +remotes = ["http://0.0.0.0:7799", "ws://0.0.0.0:7800"] [ledger] reset = true diff --git a/test-integration/configs/schedulecommit-conf-fees.ephem.toml b/test-integration/configs/schedulecommit-conf-fees.ephem.toml index 4052b1611..f9920a2e9 100644 --- a/test-integration/configs/schedulecommit-conf-fees.ephem.toml +++ b/test-integration/configs/schedulecommit-conf-fees.ephem.toml @@ -2,13 +2,7 @@ lifecycle = "ephemeral" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:8899" -[[remote]] -kind = "rpc" -url = "http://0.0.0.0:7799" - -[[remote]] -kind = "websocket" -url = "ws://0.0.0.0:7800" +remotes = ["http://0.0.0.0:7799", "ws://0.0.0.0:7800"] [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/schedulecommit-conf.devnet.toml b/test-integration/configs/schedulecommit-conf.devnet.toml index 114b12802..a0fd36d6f 100644 --- a/test-integration/configs/schedulecommit-conf.devnet.toml +++ b/test-integration/configs/schedulecommit-conf.devnet.toml @@ -2,13 +2,7 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -[[remote]] -kind = "rpc" -url = "devnet" - -[[remote]] -kind = "websocket" -url = "devnet" +remotes = ["devnet"] [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/schedulecommit-conf.ephem.frequent-commits.toml b/test-integration/configs/schedulecommit-conf.ephem.frequent-commits.toml index c5fce6e88..dd97438d5 100644 --- a/test-integration/configs/schedulecommit-conf.ephem.frequent-commits.toml +++ b/test-integration/configs/schedulecommit-conf.ephem.frequent-commits.toml @@ -2,13 +2,7 @@ lifecycle = "ephemeral" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:8899" -[[remote]] -kind = "rpc" -url = "http://0.0.0.0:7799" - -[[remote]] -kind = "websocket" -url = "ws://0.0.0.0:7800" +remotes = ["http://0.0.0.0:7799", "ws://0.0.0.0:7800"] [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/schedulecommit-conf.ephem.toml b/test-integration/configs/schedulecommit-conf.ephem.toml index f076492c7..afac5647a 100644 --- a/test-integration/configs/schedulecommit-conf.ephem.toml +++ b/test-integration/configs/schedulecommit-conf.ephem.toml @@ -2,13 +2,7 @@ lifecycle = "ephemeral" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:8899" -[[remote]] -kind = "rpc" -url = "http://0.0.0.0:7799" - -[[remote]] -kind = "websocket" -url = "ws://0.0.0.0:7800" +remotes = ["http://0.0.0.0:7799", "ws://0.0.0.0:7800"] [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/configs/validator-offline.devnet.toml b/test-integration/configs/validator-offline.devnet.toml index cf66953a3..4e85c45cc 100644 --- a/test-integration/configs/validator-offline.devnet.toml +++ b/test-integration/configs/validator-offline.devnet.toml @@ -2,13 +2,7 @@ lifecycle = "offline" commit = { compute-unit-price = 1_000_000 } listen = "0.0.0.0:7799" -[[remote]] -kind = "rpc" -url = "devnet" - -[[remote]] -kind = "websocket" -url = "devnet" +remotes = ["devnet"] [accountsdb] # size of the main storage, we have to preallocate in advance diff --git a/test-integration/test-config/src/lib.rs b/test-integration/test-config/src/lib.rs index 8c2c0960f..8a1940b02 100644 --- a/test-integration/test-config/src/lib.rs +++ b/test-integration/test-config/src/lib.rs @@ -14,7 +14,7 @@ use magicblock_config::{ accounts::AccountsDbConfig, chain::ChainLinkConfig, ledger::LedgerConfig, LifecycleMode, LoadableProgram, }, - types::{crypto::SerdePubkey, RemoteConfig, RemoteKind}, + types::{crypto::SerdePubkey, network::Remote}, ValidatorParams, }; use program_flexi_counter::instruction::{ @@ -45,16 +45,8 @@ pub fn start_validator_with_clone_config( programs, lifecycle: LifecycleMode::Ephemeral, remotes: vec![ - RemoteConfig { - kind: RemoteKind::Rpc, - url: IntegrationTestContext::url_chain().to_string(), - api_key: None, - }, - RemoteConfig { - kind: RemoteKind::Websocket, - url: IntegrationTestContext::ws_url_chain().to_string(), - api_key: None, - }, + Remote::from_str(IntegrationTestContext::url_chain()).unwrap(), + Remote::from_str(IntegrationTestContext::ws_url_chain()).unwrap(), ], chainlink: ChainLinkConfig { prepare_lookup_tables, diff --git a/test-integration/test-config/tests/auto_airdrop_feepayer.rs b/test-integration/test-config/tests/auto_airdrop_feepayer.rs index 85adba561..5989e580f 100644 --- a/test-integration/test-config/tests/auto_airdrop_feepayer.rs +++ b/test-integration/test-config/tests/auto_airdrop_feepayer.rs @@ -10,7 +10,7 @@ use magicblock_config::{ accounts::AccountsDbConfig, chain::ChainLinkConfig, ledger::LedgerConfig, LifecycleMode, }, - types::{RemoteConfig, RemoteKind}, + types::network::Remote, ValidatorParams, }; use solana_sdk::{signature::Keypair, signer::Signer, system_instruction}; @@ -24,16 +24,8 @@ fn test_auto_airdrop_feepayer_balance_after_tx() { let config = ValidatorParams { lifecycle: LifecycleMode::Ephemeral, remotes: vec![ - RemoteConfig { - kind: RemoteKind::Rpc, - url: IntegrationTestContext::url_chain().to_string(), - api_key: None, - }, - RemoteConfig { - kind: RemoteKind::Websocket, - url: IntegrationTestContext::ws_url_chain().to_string(), - api_key: None, - }, + Remote::from_str(IntegrationTestContext::url_chain()).unwrap(), + Remote::from_str(IntegrationTestContext::ws_url_chain()).unwrap(), ], accountsdb: AccountsDbConfig::default(), chainlink: ChainLinkConfig { diff --git a/test-integration/test-ledger-restore/src/lib.rs b/test-integration/test-ledger-restore/src/lib.rs index 117bc1b9c..58db033b5 100644 --- a/test-integration/test-ledger-restore/src/lib.rs +++ b/test-integration/test-ledger-restore/src/lib.rs @@ -20,7 +20,7 @@ use magicblock_config::{ LifecycleMode, LoadableProgram, }, consts::DEFAULT_LEDGER_BLOCK_TIME_MS, - types::{crypto::SerdePubkey, RemoteConfig, RemoteKind, StorageDirectory}, + types::{crypto::SerdePubkey, network::Remote, StorageDirectory}, ValidatorParams, }; use program_flexi_counter::{ @@ -148,16 +148,8 @@ pub fn setup_validator_with_local_remote_and_resume_strategy( task_scheduler: TaskSchedulerConfig { reset: true }, lifecycle: LifecycleMode::Ephemeral, remotes: vec![ - RemoteConfig { - kind: RemoteKind::Rpc, - url: IntegrationTestContext::url_chain().to_string(), - api_key: None, - }, - RemoteConfig { - kind: RemoteKind::Websocket, - url: IntegrationTestContext::ws_url_chain().to_string(), - api_key: None, - }, + Remote::from_str(IntegrationTestContext::url_chain()).unwrap(), + Remote::from_str(IntegrationTestContext::ws_url_chain()).unwrap(), ], storage: StorageDirectory(ledger_path.to_path_buf()), ..Default::default() diff --git a/test-integration/test-task-scheduler/src/lib.rs b/test-integration/test-task-scheduler/src/lib.rs index 2154f573d..d819f7ed6 100644 --- a/test-integration/test-task-scheduler/src/lib.rs +++ b/test-integration/test-task-scheduler/src/lib.rs @@ -16,7 +16,7 @@ use magicblock_config::{ scheduler::TaskSchedulerConfig, validator::ValidatorConfig, LifecycleMode, }, - types::{RemoteConfig, RemoteKind, StorageDirectory}, + types::{network::Remote, StorageDirectory}, ValidatorParams, }; use program_flexi_counter::instruction::{ @@ -35,16 +35,8 @@ pub fn setup_validator() -> (TempDir, Child, IntegrationTestContext) { let config = ValidatorParams { lifecycle: LifecycleMode::Ephemeral, remotes: vec![ - RemoteConfig { - kind: RemoteKind::Rpc, - url: IntegrationTestContext::url_chain().to_string(), - api_key: None, - }, - RemoteConfig { - kind: RemoteKind::Websocket, - url: IntegrationTestContext::ws_url_chain().to_string(), - api_key: None, - }, + Remote::from_str(IntegrationTestContext::url_chain()).unwrap(), + Remote::from_str(IntegrationTestContext::ws_url_chain()).unwrap(), ], accountsdb: AccountsDbConfig::default(), task_scheduler: TaskSchedulerConfig { reset: true }, diff --git a/test-integration/test-tools/src/toml_to_args.rs b/test-integration/test-tools/src/toml_to_args.rs index b4a038aac..08b5af4f7 100644 --- a/test-integration/test-tools/src/toml_to_args.rs +++ b/test-integration/test-tools/src/toml_to_args.rs @@ -4,7 +4,7 @@ use std::{ str::FromStr, }; -use magicblock_config::types::{resolve_url, RemoteKind}; +use magicblock_config::types::network::Remote; use serde::Deserialize; #[derive(Deserialize)] @@ -23,18 +23,24 @@ struct RemoteConfig { } impl RemoteConfig { - /// Returns the URL for this remote, resolving aliases based on kind. + /// Returns the URL for this remote, parsing the kind and url into a Remote instance. fn url(&self) -> String { - // Convert string kind to RemoteKind enum - let kind = match self.kind.as_str() { - "rpc" => RemoteKind::Rpc, - "websocket" => RemoteKind::Websocket, - "grpc" => RemoteKind::Grpc, - // Default to rpc for unknown kinds - _ => RemoteKind::Rpc, + // Construct the full remote URL with the appropriate scheme + let full_url = match self.kind.as_str() { + "rpc" => format!("http://{}", self.url), + "websocket" => format!("ws://{}", self.url), + "grpc" => format!("grpc://{}", self.url), + // Default to http for unknown kinds + _ => format!("http://{}", self.url), }; - // Use the production resolve_url function from magicblock-config - resolve_url(kind, &self.url) + + // Parse the full URL into a Remote instance to resolve aliases + if let Ok(remote) = Remote::from_str(&full_url) { + remote.url_str().to_string() + } else { + // If parsing fails, return the raw URL + self.url.clone() + } } }