diff --git a/hyperdrive/Cargo.toml b/hyperdrive/Cargo.toml index 5aa833571..cce7373b8 100644 --- a/hyperdrive/Cargo.toml +++ b/hyperdrive/Cargo.toml @@ -49,6 +49,7 @@ chrono = "0.4.31" clap = { version = "4.4", features = ["derive"] } crossterm = { version = "0.27.0", features = ["event-stream", "bracketed-paste"] } dashmap = "5.5.3" +dirs = "5.0" futures = "0.3" generic-array = "0.14.7" hex = "0.4.3" diff --git a/hyperdrive/packages/hns-indexer/hns-indexer/src/lib.rs b/hyperdrive/packages/hns-indexer/hns-indexer/src/lib.rs index 8bc7575ec..76cdde67b 100644 --- a/hyperdrive/packages/hns-indexer/hns-indexer/src/lib.rs +++ b/hyperdrive/packages/hns-indexer/hns-indexer/src/lib.rs @@ -7,9 +7,10 @@ use alloy_sol_types::SolEvent; use hyperware::process::standard::clear_state; use hyperware_process_lib::logging::{debug, error, info, init_logging, warn, Level}; use hyperware_process_lib::{ - await_message, call_init, eth, get_state, hypermap, net, set_state, timer, Address, Capability, - Message, Request, Response, + await_message, call_init, eth, get_state, hypermap, net, our, set_state, timer, vfs, Address, + Capability, Message, Request, Response, }; +use std::sync::{Mutex, OnceLock}; use std::{ collections::{BTreeMap, HashMap, HashSet}, net::{IpAddr, Ipv4Addr, Ipv6Addr}, @@ -31,15 +32,17 @@ const SUBSCRIPTION_TIMEOUT_S: u64 = 60; const DELAY_MS: u64 = 2_000; const CHECKPOINT_MS: u64 = 5 * 60 * 1_000; // 5 minutes +static NODES: OnceLock>> = OnceLock::new(); + #[cfg(not(feature = "simulation-mode"))] -const DEFAULT_NODES: &[&str] = &[ +const DEFAULT_NODES_FALLBACK: &[&str] = &[ "us-cacher-1.hypr", "eu-cacher-1.hypr", "nick.hypr", "nick1udwig.os", ]; #[cfg(feature = "simulation-mode")] -const DEFAULT_NODES: &[&str] = &["fake.os"]; +const DEFAULT_NODES_FALLBACK: &[&str] = &["fake.os"]; type PendingNotes = BTreeMap>; @@ -707,6 +710,55 @@ impl From for WitState { } } +// Function to get nodes (replaces direct access to DEFAULT_NODES) +fn get_nodes() -> Vec { + NODES.get_or_init(|| { + // Try to read from initfiles drive + match vfs::create_drive(our().package_id(), "initfiles", None) { + Ok(alt_drive_path) => { + match vfs::open_file(&format!("{}/cache_sources", alt_drive_path), false, None) { + Ok(file) => { + match file.read() { + Ok(contents) => { + let content_str = String::from_utf8_lossy(&contents); + info!("Contents of cache_sources: {}", content_str); + + // Parse the JSON to get the vector of node names + match serde_json::from_str::>(&content_str) { + Ok(custom_nodes) => { + if !custom_nodes.is_empty() { + info!("Loading custom nodes: {:?}", custom_nodes); + return Mutex::new(custom_nodes); + } else { + info!("Custom nodes list is empty, using defaults"); + } + } + Err(e) => { + info!("Failed to parse cache_sources as JSON: {}, using defaults", e); + } + } + } + Err(e) => { + info!("Failed to read cache_sources: {}, using defaults", e); + } + } + } + Err(e) => { + info!("Failed to open cache_sources: {}, using defaults", e); + } + } + } + Err(e) => { + info!("Failed to create drive: {}, using defaults", e); + } + } + + // Fallback to default nodes + let default_nodes: Vec = DEFAULT_NODES_FALLBACK.iter().map(|s| s.to_string()).collect(); + Mutex::new(default_nodes) + }).lock().unwrap().clone() +} + fn make_filters() -> (eth::Filter, eth::Filter) { let hypermap_address = eth::Address::from_str(hypermap::HYPERMAP_ADDRESS).unwrap(); // sub_id: 1 @@ -805,7 +857,7 @@ fn main(our: &Address, state: &mut StateV1) -> anyhow::Result<()> { // if block in state is < current_block, get logs from that part. info!("syncing old logs from block: {}", state.last_block); - let nodes: HashSet = DEFAULT_NODES.iter().map(|s| s.to_string()).collect(); + let nodes: HashSet = get_nodes().iter().map(|s| s.to_string()).collect(); state.fetch_and_process_logs(nodes); // set a timer tick so any pending logs will be processed diff --git a/hyperdrive/packages/hypermap-cacher/hypermap-cacher/src/lib.rs b/hyperdrive/packages/hypermap-cacher/hypermap-cacher/src/lib.rs index 987e1d1ad..e64affc86 100644 --- a/hyperdrive/packages/hypermap-cacher/hypermap-cacher/src/lib.rs +++ b/hyperdrive/packages/hypermap-cacher/hypermap-cacher/src/lib.rs @@ -558,6 +558,55 @@ impl State { // Try to bootstrap from other hypermap-cacher nodes fn try_bootstrap_from_nodes(&mut self) -> anyhow::Result<()> { + // Create alternate drive for initfiles and read the test data + let alt_drive_path = vfs::create_drive(our().package_id(), "initfiles", None).unwrap(); + + // Try to read the cache_sources file from the initfiles drive + match vfs::open_file(&format!("{}/cache_sources", alt_drive_path), false, None) { + Ok(file) => { + match file.read() { + Ok(contents) => { + let content_str = String::from_utf8_lossy(&contents); + info!("Contents of cache_sources: {}", content_str); + + // Parse the JSON to get the vector of node names + match serde_json::from_str::>(&content_str) { + Ok(custom_cache_nodes) => { + if !custom_cache_nodes.is_empty() { + info!( + "Loading custom cache source nodes: {:?}", + custom_cache_nodes + ); + // Clear existing nodes and add custom ones + self.nodes.clear(); + for node_name in custom_cache_nodes { + self.nodes.push(node_name.clone()); + } + } else { + info!("Custom cache nodes list is empty, keeping existing node configuration"); + } + } + Err(e) => { + info!("Failed to parse cache_sources as JSON: {}, keeping existing node configuration", e); + } + } + } + Err(e) => { + info!( + "Failed to read cache_sources: {}, keeping existing node configuration", + e + ); + } + } + } + Err(e) => { + info!( + "Failed to open cache_sources: {}, keeping existing node configuration", + e + ); + } + } + if self.nodes.is_empty() { info!("No nodes configured for bootstrap, will fallback to RPC"); return Err(anyhow::anyhow!("No nodes configured for bootstrap")); @@ -693,6 +742,33 @@ impl State { Err(anyhow::anyhow!("Failed to bootstrap from any node")) } + // Helper function to write nodes to cache_sources file + fn write_nodes_to_file(&self) -> anyhow::Result<()> { + info!("Beginning of subroutine"); + let alt_drive_path = vfs::create_drive(our().package_id(), "initfiles", None)?; + info!("drive path defined"); + let nodes_json = serde_json::to_string(&self.nodes)?; + info!("nodes_json defined"); + let file_path = format!("{}/cache_sources", alt_drive_path); + info!("file_path defined"); + + // Open file in write mode which should truncate, but to be safe we'll write exact bytes + let mut file = vfs::open_file(&file_path, true, None)?; + + // Get the bytes to write + let bytes = nodes_json.as_bytes(); + + // Write all bytes + file.write_all(bytes)?; + + // Explicitly set the file length to the exact size of what we wrote + // This ensures any old content beyond this point is truncated + file.set_len(bytes.len() as u64)?; + + info!("Updated cache_sources with {} nodes", self.nodes.len()); + Ok(()) + } + // Process received log caches and write them to VFS fn process_received_log_caches( &mut self, @@ -1189,6 +1265,9 @@ fn handle_request( } state.nodes = new_nodes; state.save(); + if let Err(e) = state.write_nodes_to_file() { + error!("Failed to write nodes to cache_sources: {:?}", e); + } info!("Nodes updated to: {:?}", state.nodes); CacherResponse::SetNodes(Ok("Nodes updated successfully".to_string())) } @@ -1215,7 +1294,9 @@ fn handle_request( *state = State::new(&state.drive_path); state.nodes = nodes; state.save(); - + if let Err(e) = state.write_nodes_to_file() { + error!("Failed to write nodes to cache_sources: {:?}", e); + } info!( "Hypermap-cacher reset complete. New nodes: {:?}", state.nodes @@ -1343,6 +1424,24 @@ fn init(our: Address) { info!("Hypermap Cacher process starting..."); let drive_path = vfs::create_drive(our.package_id(), "cache", None).unwrap(); + // Create alternate drive for initfiles and read the test data + let alt_drive_path = vfs::create_drive(our.package_id(), "initfiles", None).unwrap(); + + // Try to read the cache_sources file from the initfiles drive + match vfs::open_file(&format!("{}/cache_sources", alt_drive_path), false, None) { + Ok(file) => match file.read() { + Ok(contents) => { + let content_str = String::from_utf8_lossy(&contents); + info!("Contents of cache_sources: {}", content_str); + } + Err(e) => { + info!("Failed to read cache_sources: {}", e); + } + }, + Err(e) => { + info!("Failed to open cache_sources: {}", e); + } + } let bind_config = http::server::HttpBindingConfig::default().authenticated(false); let mut server = http::server::HttpServer::new(5); diff --git a/hyperdrive/src/eth/mod.rs b/hyperdrive/src/eth/mod.rs index e8d67acb1..9b5d3630e 100644 --- a/hyperdrive/src/eth/mod.rs +++ b/hyperdrive/src/eth/mod.rs @@ -1460,9 +1460,11 @@ async fn handle_eth_config_action( }; } if save_providers { + let saved_configs = providers_to_saved_configs(&state.providers); + if let Ok(()) = tokio::fs::write( state.home_directory_path.join(".eth_providers"), - serde_json::to_string(&providers_to_saved_configs(&state.providers)).unwrap(), + serde_json::to_string(&saved_configs).unwrap(), ) .await { diff --git a/hyperdrive/src/eth_config_utils.rs b/hyperdrive/src/eth_config_utils.rs new file mode 100644 index 000000000..b5b713b43 --- /dev/null +++ b/hyperdrive/src/eth_config_utils.rs @@ -0,0 +1,71 @@ +use lib::eth::{ProviderConfig, SavedConfigs}; + +pub fn add_provider_to_config( + eth_provider_config: &mut SavedConfigs, + new_provider: ProviderConfig, +) { + match &new_provider.provider { + lib::eth::NodeOrRpcUrl::RpcUrl { url, .. } => { + // Remove any existing provider with this URL + eth_provider_config.0.retain(|config| { + if let lib::eth::NodeOrRpcUrl::RpcUrl { + url: existing_url, .. + } = &config.provider + { + existing_url != url + } else { + true + } + }); + } + lib::eth::NodeOrRpcUrl::Node { hns_update, .. } => { + // Remove any existing provider with this node name + eth_provider_config.0.retain(|config| { + if let lib::eth::NodeOrRpcUrl::Node { + hns_update: existing_update, + .. + } = &config.provider + { + existing_update.name != hns_update.name + } else { + true + } + }); + } + } + + // Insert the new provider at the front (position 0) + eth_provider_config.0.insert(0, new_provider); +} + +/// Extract unauthenticated RPC URLs from SavedConfigs +pub fn extract_rpc_url_providers_for_default_chain( + saved_configs: &lib::eth::SavedConfigs, +) -> Vec { + saved_configs + .0 + .iter() + .filter_map(|provider_config| { + // Only include providers for the default chain (8453 for mainnet, 31337 for simulation) + #[cfg(not(feature = "simulation-mode"))] + let target_chain_id = crate::CHAIN_ID; // 8453 + #[cfg(feature = "simulation-mode")] + let target_chain_id = crate::CHAIN_ID; // 31337 + + if provider_config.chain_id != target_chain_id { + return None; + } + + match &provider_config.provider { + lib::eth::NodeOrRpcUrl::RpcUrl { url, auth } => { + // Return the full RpcUrl enum variant with both url and auth + Some(lib::eth::NodeOrRpcUrl::RpcUrl { + url: url.clone(), + auth: auth.clone(), + }) + } + lib::eth::NodeOrRpcUrl::Node { .. } => None, // Skip node providers + } + }) + .collect() +} diff --git a/hyperdrive/src/main.rs b/hyperdrive/src/main.rs index bd087a5ba..021950a88 100644 --- a/hyperdrive/src/main.rs +++ b/hyperdrive/src/main.rs @@ -1,3 +1,4 @@ +use crate::eth_config_utils::add_provider_to_config; use anyhow::Result; use clap::{arg, value_parser, Command}; use lib::types::core::{ @@ -12,9 +13,11 @@ use std::collections::HashMap; use std::env; use std::path::Path; use std::sync::Arc; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, oneshot}; mod eth; +mod eth_config_utils; + #[cfg(feature = "simulation-mode")] mod fakenet; pub mod fd_manager; @@ -283,32 +286,99 @@ async fn main() { println!("Serving Hyperdrive at {link}\r"); #[cfg(not(feature = "simulation-mode"))] println!("Login or register at {link}\r"); + #[cfg(not(feature = "simulation-mode"))] - let (our, encoded_keyfile, decoded_keyfile) = match password { - None => { - serve_register_fe( - &home_directory_path, - our_ip.to_string(), - (ws_tcp_handle, ws_flag_used), - (tcp_tcp_handle, tcp_flag_used), - http_server_port, - eth_provider_config.clone(), - detached, - ) - .await - } - Some(password) => { - login_with_password( - &home_directory_path, - our_ip.to_string(), - (ws_tcp_handle, ws_flag_used), - (tcp_tcp_handle, tcp_flag_used), - eth_provider_config.clone(), - password, - ) - .await + let (our, encoded_keyfile, decoded_keyfile, cache_source_vector, base_l2_access_source_vector) = + match password { + None => { + let result = serve_register_fe( + &home_directory_path, + our_ip.to_string(), + (ws_tcp_handle, ws_flag_used), + (tcp_tcp_handle, tcp_flag_used), + http_server_port, + eth_provider_config.clone(), + detached, + ) + .await; + result + } + Some(password) => { + let result = login_with_password( + &home_directory_path, + our_ip.to_string(), + (ws_tcp_handle, ws_flag_used), + (tcp_tcp_handle, tcp_flag_used), + eth_provider_config.clone(), + password, + ) + .await; + result + } + }; + + is_eth_provider_config_updated = false; + + if !base_l2_access_source_vector.is_empty() { + // Process in reverse order so the first entry in the vector becomes highest priority + for (_reverse_index, provider_str) in + base_l2_access_source_vector.into_iter().rev().enumerate() + { + // Parse the JSON string to extract url and auth fields + match serde_json::from_str::(&provider_str) { + Ok(provider_json) => { + let url = match provider_json.get("url").and_then(|v| v.as_str()) { + Some(u) => u.to_string(), + None => { + eprintln!( + "Skipping provider entry with missing or invalid 'url' field" + ); + continue; + } + }; + + // Extract auth field - convert null to None + let auth = match provider_json.get("auth") { + Some(auth_value) if !auth_value.is_null() => { + // Try to deserialize the auth object into Authorization + match serde_json::from_value::( + auth_value.clone(), + ) { + Ok(auth_obj) => Some(auth_obj), + Err(e) => { + eprintln!("Failed to parse auth field for {}: {:?}", url, e); + None + } + } + } + _ => None, // null or missing -> None + }; + + let new_provider = lib::eth::ProviderConfig { + chain_id: CHAIN_ID, + trusted: true, + provider: lib::eth::NodeOrRpcUrl::RpcUrl { url, auth }, + }; + + add_provider_to_config(&mut eth_provider_config, new_provider); + } + Err(e) => { + eprintln!("Failed to parse provider JSON '{}': {:?}", provider_str, e); + } + } } - }; + is_eth_provider_config_updated = true; + } + + if is_eth_provider_config_updated { + // save the new provider config + tokio::fs::write( + home_directory_path.join(".eth_providers"), + serde_json::to_string(ð_provider_config).unwrap(), + ) + .await + .expect("failed to save new eth provider config!"); + } // the boolean flag determines whether the runtime module is *public* or not, // where public means that any process can always message it. @@ -397,6 +467,53 @@ async fn main() { .await .expect("state load failed!"); + // Create the nested directory structure before spawning tasks + let vfs_dir = home_directory_path.join("vfs"); + let hypermap_cacher_dir = vfs_dir.join("hypermap-cacher:sys"); + let initfiles_dir = hypermap_cacher_dir.join("initfiles"); + + // Create all directories at once (creates parent directories if they don't exist) + if let Err(e) = tokio::fs::create_dir_all(&initfiles_dir).await { + eprintln!( + "Failed to create directory structure {}: {}", + initfiles_dir.display(), + e + ); + // You might want to handle this error based on your needs + } + + // Create the cache_sources file with test content + let data_file_path = initfiles_dir.join("cache_sources"); + + // Write cache_source_vector to cache_sources as JSON + let cache_json = serde_json::to_string_pretty(&cache_source_vector) + .expect("Failed to serialize cache_source_vector to JSON"); + let cache_json_clone = cache_json.clone(); + + if let Err(e) = tokio::fs::write(&data_file_path, cache_json).await { + eprintln!("Warning: Failed to write cache data to file: {}", e); + } + + // Create the second directory structure for hns-indexer:sys + let hns_indexer_dir = vfs_dir.join("hns-indexer:sys"); + let hns_initfiles_dir = hns_indexer_dir.join("initfiles"); + + // Create all directories at once for the second location + if let Err(e) = tokio::fs::create_dir_all(&hns_initfiles_dir).await { + eprintln!( + "Failed to create directory structure {}: {}", + hns_initfiles_dir.display(), + e + ); + } + + // Create the cache_sources file in the second location + let hns_data_file_path = hns_initfiles_dir.join("cache_sources"); + + if let Err(e) = tokio::fs::write(&hns_data_file_path, cache_json_clone).await { + eprintln!("Warning: Failed to write cache data to second file: {}", e); + } + let mut tasks = tokio::task::JoinSet::>::new(); tasks.spawn(kernel::kernel( our.clone(), @@ -866,6 +983,7 @@ async fn find_public_ip() -> std::net::Ipv4Addr { /// username, networking key, and routing info. /// if any do not match, we should prompt user to create a "transaction" /// that updates their PKI info on-chain. + #[cfg(not(feature = "simulation-mode"))] async fn serve_register_fe( home_directory_path: &Path, @@ -875,42 +993,75 @@ async fn serve_register_fe( http_server_port: u16, eth_provider_config: lib::eth::SavedConfigs, detached: bool, -) -> (Identity, Vec, Keyfile) { - let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::(); - - let disk_keyfile: Option> = tokio::fs::read(home_directory_path.join(".keys")) - .await - .ok(); - - let (tx, mut rx) = mpsc::channel::<(Identity, Keyfile, Vec)>(1); - let (our, decoded_keyfile, encoded_keyfile) = tokio::select! { - _ = register::register( - tx, - kill_rx, - our_ip, - (ws_networking.0.as_ref(), ws_networking.1), - (tcp_networking.0.as_ref(), tcp_networking.1), - http_server_port, - disk_keyfile, - eth_provider_config, - detached) => { - panic!("registration failed") - } - Some((our, decoded_keyfile, encoded_keyfile)) = rx.recv() => { - (our, decoded_keyfile, encoded_keyfile) +) -> (Identity, Vec, Keyfile, Vec, Vec) { + let cache_sources_from_file = { + let vfs_dir = home_directory_path.join("vfs"); + let hypermap_cacher_dir = vfs_dir.join("hypermap-cacher:sys"); + let initfiles_dir = hypermap_cacher_dir.join("initfiles"); + let data_file_path = initfiles_dir.join("cache_sources"); + + match tokio::fs::read_to_string(&data_file_path).await { + Ok(contents) => match serde_json::from_str::>(&contents) { + Ok(cache_sources) if !cache_sources.is_empty() => { + println!("Loaded cache sources: {:?}\r", cache_sources); + Some(cache_sources) + } + _ => Some(Vec::new()), + }, + Err(_) => Some(Vec::new()), } }; - tokio::fs::write(home_directory_path.join(".keys"), &encoded_keyfile) - .await - .unwrap(); + // Convert RpcUrl providers to serializable format for the registration UI + let initial_base_l2_providers = { + // Extract from eth_provider_config and serialize to JSON strings + let rpc_providers = + eth_config_utils::extract_rpc_url_providers_for_default_chain(ð_provider_config); + serialize_rpc_providers_for_ui(rpc_providers) + }; + + let (tx, mut rx): ( + mpsc::Sender<(Identity, Keyfile, Vec, Vec, Vec)>, + mpsc::Receiver<(Identity, Keyfile, Vec, Vec, Vec)>, + ) = mpsc::channel(32); + let (kill_tx, kill_rx) = oneshot::channel::(); + + let keyfile_path = home_directory_path.join(".keys"); + let keyfile: Option> = tokio::fs::read(&keyfile_path).await.ok(); + + tokio::spawn(async move { + register::register( + tx, + kill_rx, + our_ip, + (ws_networking.0.as_ref(), ws_networking.1), + (tcp_networking.0.as_ref(), tcp_networking.1), + http_server_port, + keyfile, + eth_provider_config, + detached, + cache_sources_from_file, // Pass cache sources from config + Some(initial_base_l2_providers), // Pass providers - now properly unwrapped + ) + .await; + }); + let (our, decoded_keyfile, encoded_keyfile, cache_sources, base_l2_providers) = + rx.recv().await.unwrap(); let _ = kill_tx.send(true); - drop(ws_networking.0); - drop(tcp_networking.0); + // Save the keyfile to disk + tokio::fs::write(home_directory_path.join(".keys"), &encoded_keyfile) + .await + .expect("failed to write keyfile"); - (our, encoded_keyfile, decoded_keyfile) + ( + our, + encoded_keyfile, + decoded_keyfile, + cache_sources, + base_l2_providers, + ) } #[cfg(not(feature = "simulation-mode"))] @@ -921,7 +1072,7 @@ async fn login_with_password( tcp_networking: (Option, bool), eth_provider_config: lib::eth::SavedConfigs, password: &str, -) -> (Identity, Vec, Keyfile) { +) -> (Identity, Vec, Keyfile, Vec, Vec) { use argon2::Argon2; use ring::signature::KeyPair; @@ -1014,48 +1165,70 @@ async fn login_with_password( .await .unwrap(); - (our, disk_keyfile, k) -} - -/// Add a provider config with deduplication logic (same as runtime system) -fn add_provider_to_config( - eth_provider_config: &mut lib::eth::SavedConfigs, - new_provider: lib::eth::ProviderConfig, -) { - match &new_provider.provider { - lib::eth::NodeOrRpcUrl::RpcUrl { url, .. } => { - // Remove any existing provider with this URL - eth_provider_config.0.retain(|config| { - if let lib::eth::NodeOrRpcUrl::RpcUrl { - url: existing_url, .. - } = &config.provider - { - existing_url != url - } else { - true - } - }); - } - lib::eth::NodeOrRpcUrl::Node { hns_update, .. } => { - // Remove any existing provider with this node name - eth_provider_config.0.retain(|config| { - if let lib::eth::NodeOrRpcUrl::Node { - hns_update: existing_update, - .. - } = &config.provider - { - existing_update.name != hns_update.name - } else { - true - } - }); - } - } + let cache_source_vector: Vec = Vec::new(); + let base_l2_access_source_vector: Vec = Vec::new(); - // Insert the new provider at the front (position 0) - eth_provider_config.0.insert(0, new_provider); + ( + our, + disk_keyfile, + k, + cache_source_vector, + base_l2_access_source_vector, + ) } fn make_remote_link(url: &str, text: &str) -> String { format!("\x1B]8;;{}\x1B\\{}\x1B]8;;\x1B\\", url, text) } + +/// Serialize RpcUrl providers to JSON strings for the UI +/// Each provider is serialized as a JSON object containing url and auth +pub fn serialize_rpc_providers_for_ui(providers: Vec) -> Vec { + providers + .into_iter() + .filter_map(|provider| { + match provider { + lib::eth::NodeOrRpcUrl::RpcUrl { url, auth } => { + // Create a serializable object with url and auth + let provider_obj = serde_json::json!({ + "url": url, + "auth": auth + }); + // Serialize to string + serde_json::to_string(&provider_obj).ok() + } + lib::eth::NodeOrRpcUrl::Node { .. } => None, + } + }) + .collect() +} + +/// Deserialize RpcUrl provider strings from the UI back to NodeOrRpcUrl objects +pub fn deserialize_rpc_providers_from_ui( + provider_strings: Vec, +) -> Vec { + provider_strings + .into_iter() + .filter_map(|provider_str| { + // Try to parse as JSON object first + if let Ok(provider_obj) = serde_json::from_str::(&provider_str) { + if let (Some(url), auth) = ( + provider_obj + .get("url") + .and_then(|v| v.as_str()) + .map(String::from), + provider_obj + .get("auth") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + ) { + return Some(lib::eth::NodeOrRpcUrl::RpcUrl { url, auth }); + } + } + // Fall back to treating it as a plain URL string (for backward compatibility) + Some(lib::eth::NodeOrRpcUrl::RpcUrl { + url: provider_str, + auth: None, + }) + }) + .collect() +} diff --git a/hyperdrive/src/register-ui/src/abis/helpers.ts b/hyperdrive/src/register-ui/src/abis/helpers.ts index b4765ed46..cd2db8602 100644 --- a/hyperdrive/src/register-ui/src/abis/helpers.ts +++ b/hyperdrive/src/register-ui/src/abis/helpers.ts @@ -19,6 +19,7 @@ export const generateNetworkingKeys = async ({ setTcpPort, setRouters, reset, + customRouters, }: { direct: boolean, label: string, @@ -29,6 +30,7 @@ export const generateNetworkingKeys = async ({ setTcpPort: (tcpPort: number) => void; setRouters: (routers: string[]) => void; reset: boolean; + customRouters?: string[]; }) => { const { networking_key, @@ -45,13 +47,16 @@ export const generateNetworkingKeys = async ({ const ipAddress = ipToBytes(ip_address); + const routersToUse = customRouters && customRouters.length > 0 ? customRouters : allowed_routers; + setNetworkingKey(networking_key); // setIpAddress(ipAddress); setWsPort(ws_port || 0); setTcpPort(tcp_port || 0); - setRouters(allowed_routers); + setRouters(routersToUse); console.log("networking_key: ", networking_key); + console.log("routers being used: ", routersToUse); const netkeycall = encodeFunctionData({ abi: hypermapAbi, @@ -92,7 +97,7 @@ export const generateNetworkingKeys = async ({ ] }); - const encodedRouters = encodeRouters(allowed_routers); + const encodedRouters = encodeRouters(routersToUse); const router_call = encodeFunctionData({ diff --git a/hyperdrive/src/register-ui/src/components/BaseL2AccessProviderTooltip.tsx b/hyperdrive/src/register-ui/src/components/BaseL2AccessProviderTooltip.tsx new file mode 100644 index 000000000..6c856378a --- /dev/null +++ b/hyperdrive/src/register-ui/src/components/BaseL2AccessProviderTooltip.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { Tooltip } from "./Tooltip"; + +export const BaseL2AccessProviderTooltip: React.FC = () => { + return ( + + ); +}; \ No newline at end of file diff --git a/hyperdrive/src/register-ui/src/components/CacheSourceTooltip.tsx b/hyperdrive/src/register-ui/src/components/CacheSourceTooltip.tsx new file mode 100644 index 000000000..ec83b1ea1 --- /dev/null +++ b/hyperdrive/src/register-ui/src/components/CacheSourceTooltip.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { Tooltip } from "./Tooltip"; + +export const CacheSourceTooltip: React.FC = () => { + return ( + + ); +}; \ No newline at end of file diff --git a/hyperdrive/src/register-ui/src/components/DirectCheckbox.tsx b/hyperdrive/src/register-ui/src/components/DirectCheckbox.tsx index c11b5ad64..72a32b182 100644 --- a/hyperdrive/src/register-ui/src/components/DirectCheckbox.tsx +++ b/hyperdrive/src/register-ui/src/components/DirectCheckbox.tsx @@ -2,25 +2,35 @@ import { DirectTooltip } from "./DirectTooltip"; import { FaSquareCheck, FaRegSquare } from "react-icons/fa6"; interface DNCBProps { - direct: boolean; - setDirect: (direct: boolean) => void; + direct: boolean; + setDirect: (direct: boolean) => void; + initiallyChecked?: boolean; } -export default function DirectNodeCheckbox({ direct, setDirect }: DNCBProps) { - return ( -
- -
- Register as a direct node. - If you are unsure, leave unchecked. -
- -
- ); +export default function DirectNodeCheckbox({ direct, setDirect, initiallyChecked }: DNCBProps) { + const getHelpText = () => { + if (initiallyChecked === undefined) { + return "If you are unsure, leave unchecked."; + } + return initiallyChecked + ? "If you are unsure, leave checked." + : "If you are unsure, leave unchecked."; + }; + + return ( +
+ +
+ Register as a direct node. + {getHelpText()} +
+ +
+ ); } \ No newline at end of file diff --git a/hyperdrive/src/register-ui/src/components/RouterTooltip.tsx b/hyperdrive/src/register-ui/src/components/RouterTooltip.tsx new file mode 100644 index 000000000..983b8ba32 --- /dev/null +++ b/hyperdrive/src/register-ui/src/components/RouterTooltip.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { Tooltip } from "./Tooltip"; + +export const RouterTooltip: React.FC = () => { + return ( + + ); +}; \ No newline at end of file diff --git a/hyperdrive/src/register-ui/src/components/RpcProviderEditor.tsx b/hyperdrive/src/register-ui/src/components/RpcProviderEditor.tsx new file mode 100644 index 000000000..0005f1c5e --- /dev/null +++ b/hyperdrive/src/register-ui/src/components/RpcProviderEditor.tsx @@ -0,0 +1,217 @@ +import { useState } from 'react'; +import classNames from 'classnames'; + +export interface RpcProviderData { + url: string; + auth: { + type: 'Basic' | 'Bearer' | 'Raw' | null; + value: string; + } | null; +} + +interface RpcProviderEditorProps { + providers: RpcProviderData[]; + onChange: (providers: RpcProviderData[]) => void; + label?: string; +} + +export function RpcProviderEditor({ providers, onChange, label }: RpcProviderEditorProps) { + const [showAuthValues, setShowAuthValues] = useState>({}); + + const addProvider = () => { + onChange([...providers, { url: '', auth: null }]); + }; + + const removeProvider = (index: number) => { + onChange(providers.filter((_, i) => i !== index)); + // Clean up the showAuthValues state for this index + const newShowAuthValues = { ...showAuthValues }; + delete newShowAuthValues[index]; + setShowAuthValues(newShowAuthValues); + }; + + const updateProvider = (index: number, updates: Partial) => { + const newProviders = [...providers]; + newProviders[index] = { ...newProviders[index], ...updates }; + onChange(newProviders); + }; + + const updateAuth = (index: number, authType: 'Basic' | 'Bearer' | 'Raw' | null, authValue: string = '') => { + const newProviders = [...providers]; + if (authType === null) { + newProviders[index].auth = null; + } else { + newProviders[index].auth = { type: authType, value: authValue }; + } + onChange(newProviders); + }; + + const toggleAuthVisibility = (index: number) => { + setShowAuthValues(prev => ({ + ...prev, + [index]: !prev[index] + })); + }; + + // Validate individual provider + const validateProvider = (provider: RpcProviderData): string | null => { + if (!provider.url.trim()) { + return 'WebSocket URL is required'; + } + if (!provider.url.startsWith('wss://')) { + return 'URL must be a secure WebSocket URL starting with wss://'; + } + if (provider.auth && !provider.auth.value.trim()) { + return 'Auth value is required when auth type is specified'; + } + return null; + }; + + // Get validation errors for all providers + const validationErrors = providers.map(validateProvider); + const hasErrors = validationErrors.some(error => error !== null); + + return ( +
+ {label && ( + + )} + + {providers.map((provider, index) => { + const error = validationErrors[index]; + return ( +
+
+
+ {/* URL Field */} +
+ + updateProvider(index, { url: e.target.value })} + placeholder="wss://base-mainnet.infura.io/ws/v3/YOUR-API-KEY" + className={classNames("input text-sm", { + 'border-red-500 focus:border-red-500': error !== null + })} + /> +
+ + {/* Auth Type Selector */} +
+ + +
+ + {/* Auth Value Field (conditional) */} + {provider.auth && ( +
+ + +
+ updateAuth(index, provider.auth!.type, e.target.value)} + placeholder={ + provider.auth.type === 'Bearer' ? 'your-bearer-token' : + provider.auth.type === 'Basic' ? 'user:pass (base64 encoded)' : + 'custom-header-value' + } + className={classNames("input text-sm", { + 'border-red-500 focus:border-red-500': provider.auth && !provider.auth.value.trim() + })} + style={{ paddingRight: '88px' }} + autoComplete="off" + /> + +
+
+ )} +
+ + {/* Remove Button */} + +
+ + {/* Error message for this specific provider */} + {error && ( + + {error} + + )} +
+ ); + })} + + {/* Add Provider Button */} + + + {/* Overall validation summary */} + {providers.length > 0 && !hasErrors && ( + + {providers.length} provider{providers.length !== 1 ? 's' : ''} to be added + + )} +
+ ); +} \ No newline at end of file diff --git a/hyperdrive/src/register-ui/src/components/SpecifyBaseL2AccessProvidersCheckbox.tsx b/hyperdrive/src/register-ui/src/components/SpecifyBaseL2AccessProvidersCheckbox.tsx new file mode 100644 index 000000000..f44456048 --- /dev/null +++ b/hyperdrive/src/register-ui/src/components/SpecifyBaseL2AccessProvidersCheckbox.tsx @@ -0,0 +1,33 @@ +import { BaseL2AccessProviderTooltip } from "./BaseL2AccessProviderTooltip"; +import { FaSquareCheck, FaRegSquare } from "react-icons/fa6"; + +interface SpecifyBaseL2AccessProvidersProps { + specifyBaseL2AccessProviders: boolean; + setSpecifyBaseL2AccessProviders: (specifyBaseL2AccessProviders: boolean) => void; + initiallyChecked?: boolean; +} + +export default function SpecifyBaseL2AccessProvidersCheckbox({ + specifyBaseL2AccessProviders, + setSpecifyBaseL2AccessProviders, + initiallyChecked = false + }: SpecifyBaseL2AccessProvidersProps) { + return ( +
+ +
+ Add Base L2 access providers. + + If you are unsure, leave {initiallyChecked ? 'checked' : 'unchecked'}. + +
+ +
+ ); +} \ No newline at end of file diff --git a/hyperdrive/src/register-ui/src/components/SpecifyCacheSourcesCheckbox.tsx b/hyperdrive/src/register-ui/src/components/SpecifyCacheSourcesCheckbox.tsx new file mode 100644 index 000000000..fad52290f --- /dev/null +++ b/hyperdrive/src/register-ui/src/components/SpecifyCacheSourcesCheckbox.tsx @@ -0,0 +1,41 @@ + +import { CacheSourceTooltip } from "./CacheSourceTooltip"; +import { FaSquareCheck, FaRegSquare } from "react-icons/fa6"; + +interface SpecifyCacheSourcesCheckboxProps { + specifyCacheSources: boolean; + setSpecifyCacheSources: (specifyCacheSources: boolean) => void; + initiallyChecked?: boolean; +} + +export default function SpecifyCacheSourcesCheckbox({ + specifyCacheSources, + setSpecifyCacheSources, + initiallyChecked + }: SpecifyCacheSourcesCheckboxProps) { + const getHelpText = () => { + if (initiallyChecked === undefined) { + return "If you are unsure, leave unchecked."; + } + return initiallyChecked + ? "If you are unsure, leave checked." + : "If you are unsure, leave unchecked."; + }; + + return ( +
+ +
+ Specify cache sources. + {getHelpText()} +
+ +
+ ); +} \ No newline at end of file diff --git a/hyperdrive/src/register-ui/src/components/SpecifyRoutersCheckbox.tsx b/hyperdrive/src/register-ui/src/components/SpecifyRoutersCheckbox.tsx new file mode 100644 index 000000000..5c9c011c9 --- /dev/null +++ b/hyperdrive/src/register-ui/src/components/SpecifyRoutersCheckbox.tsx @@ -0,0 +1,43 @@ +import { RouterTooltip } from "./RouterTooltip"; +import { FaSquareCheck, FaRegSquare } from "react-icons/fa6"; + +interface SRCBProps { + specifyRouters: boolean; + setSpecifyRouters: (specifyRouters: boolean) => void; + initiallyChecked?: boolean; +} + +export default function SpecifyRoutersCheckbox({ + specifyRouters, + setSpecifyRouters, + initiallyChecked + }: SRCBProps) { + const getHelpText = () => { + if (initiallyChecked === undefined) { + return "If you are unsure, leave unchecked."; + } + return initiallyChecked + ? "If you are unsure, leave checked." + : "If you are unsure, leave unchecked."; + }; + + return ( +
+ +
+ Register as indirect node with non-default routers. + {getHelpText()} +
+ +
+ ); +} \ No newline at end of file diff --git a/hyperdrive/src/register-ui/src/lib/types.ts b/hyperdrive/src/register-ui/src/lib/types.ts index a47f5f304..f3afbbacc 100644 --- a/hyperdrive/src/register-ui/src/lib/types.ts +++ b/hyperdrive/src/register-ui/src/lib/types.ts @@ -43,3 +43,19 @@ export type UnencryptedIdentity = { name: string, allowed_routers: string[] } + +export type InfoResponse = { + name?: string; + allowed_routers?: string[]; + initial_cache_sources: string[]; + initial_base_l2_providers: string[]; +} + +export interface RpcProviderConfig { + url: string; + auth: { + Basic?: string; + Bearer?: string; + Raw?: string; + } | null; +} \ No newline at end of file diff --git a/hyperdrive/src/register-ui/src/pages/CommitDotOsName.tsx b/hyperdrive/src/register-ui/src/pages/CommitDotOsName.tsx index df2d06150..23f7c8bb3 100644 --- a/hyperdrive/src/register-ui/src/pages/CommitDotOsName.tsx +++ b/hyperdrive/src/register-ui/src/pages/CommitDotOsName.tsx @@ -6,7 +6,7 @@ import Loader from "../components/Loader"; import { PageProps } from "../lib/types"; import DirectNodeCheckbox from "../components/DirectCheckbox"; -import { Tooltip } from "../components/Tooltip"; +import SpecifyRoutersCheckbox from "../components/SpecifyRoutersCheckbox"; import { useAccount, useWaitForTransactionReceipt, useWriteContract } from "wagmi"; import { useConnectModal, useAddRecentTransaction } from "@rainbow-me/rainbowkit" @@ -16,16 +16,19 @@ import { base } from 'viem/chains' import BackButton from "../components/BackButton"; interface RegisterOsNameProps extends PageProps { } +// Regex for valid router names (domain format) +const ROUTER_NAME_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/; + function CommitDotOsName({ - direct, - setDirect, - setHnsName, - setNetworkingKey, - setIpAddress, - setWsPort, - setTcpPort, - setRouters, -}: RegisterOsNameProps) { + direct, + setDirect, + setHnsName, + setNetworkingKey, + setIpAddress, + setWsPort, + setTcpPort, + setRouters, + }: RegisterOsNameProps) { let { address } = useAccount(); let navigate = useNavigate(); let { openConnectModal } = useConnectModal(); @@ -47,6 +50,70 @@ function CommitDotOsName({ const [nameValidities, setNameValidities] = useState([]) const [triggerNameCheck, setTriggerNameCheck] = useState(false) const [isConfirmed, setIsConfirmed] = useState(false) + const [specifyRouters, setSpecifyRouters] = useState(false) + const [customRouters, setCustomRouters] = useState('') + const [routerValidationErrors, setRouterValidationErrors] = useState([]) + + // Modified setDirect function - no longer clears custom routers + const handleSetDirect = (value: boolean) => { + setDirect(value); + if (value) { + setSpecifyRouters(false); + } + }; + + // Modified setSpecifyRouters function - no longer clears custom routers + const handleSetSpecifyRouters = (value: boolean) => { + setSpecifyRouters(value); + if (value) { + setDirect(false); + } + }; + + // Validate custom routers against the regex + const validateRouters = (routersText: string): string[] => { + if (!routersText.trim()) return []; + + const routers = routersText + .split('\n') + .map(router => router.trim()) + .filter(router => router.length > 0); + + const errors: string[] = []; + routers.forEach((router, index) => { + if (!ROUTER_NAME_REGEX.test(router)) { + errors.push(`Line ${index + 1}: "${router}" is not a valid router name`); + } + }); + + return errors; + }; + + // Handle custom routers change with validation + const handleCustomRoutersChange = (value: string) => { + setCustomRouters(value); + if (specifyRouters && value.trim()) { + const errors = validateRouters(value); + setRouterValidationErrors(errors); + } else { + setRouterValidationErrors([]); + } + }; + + // Add a validation function for custom routers + const getValidCustomRouters = () => { + if (!specifyRouters) return []; + return customRouters + .split('\n') + .map(router => router.trim()) + .filter(router => router.length > 0 && ROUTER_NAME_REGEX.test(router)); + }; + + const isCustomRoutersValid = () => { + if (!specifyRouters) return true; // Not required if checkbox is unchecked + const validRouters = getValidCustomRouters(); + return validRouters.length > 0 && routerValidationErrors.length === 0; + }; useEffect(() => { document.title = "Register" @@ -71,6 +138,16 @@ function CommitDotOsName({ } setName(toAscii(name)); console.log("committing to .os name: ", name) + + // Process custom routers only if the checkbox is checked + if (specifyRouters && customRouters.trim()) { + const routersToUse = getValidCustomRouters(); + setRouters(routersToUse); + } else { + // Clear routers in app state if not specifying custom routers + setRouters([]); + } + const commit = keccak256( encodeAbiParameters( parseAbiParameters('bytes memory, address'), @@ -107,7 +184,7 @@ function CommitDotOsName({ throw err; } - }, [name, direct, address, writeContract, setNetworkingKey, setIpAddress, setWsPort, setTcpPort, setRouters, openConnectModal]) + }, [name, specifyRouters, customRouters, direct, address, writeContract, setNetworkingKey, setIpAddress, setWsPort, setTcpPort, setRouters, openConnectModal]) useEffect(() => { if (txConfirmed) { @@ -116,10 +193,15 @@ function CommitDotOsName({ setTimeout(() => { setIsConfirmed(true); setHnsName(`${name}.os`); + + if (specifyRouters && customRouters.trim()) { + const routersToUse = getValidCustomRouters(); + setRouters(routersToUse); + } navigate("/mint-os-name"); }, 16000) } - }, [txConfirmed, address, name, setHnsName, navigate]); + }, [txConfirmed, address, name, setHnsName, navigate, specifyRouters, customRouters, setRouters]); return (
@@ -142,27 +224,67 @@ function CommitDotOsName({

- Advanced Options - + Advanced Network Options +
+ + + {specifyRouters && ( +
+ +