diff --git a/Cargo.lock b/Cargo.lock index 06f830f89e9..6d7132b0139 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3310,6 +3310,8 @@ dependencies = [ "secp256k1", "serde", "serde_json", + "spawned-concurrency", + "spawned-rt", "thiserror 2.0.17", "tikv-jemallocator", "tokio", @@ -3711,6 +3713,8 @@ dependencies = [ "serde_json", "sha2", "sha3", + "spawned-concurrency", + "spawned-rt", "thiserror 2.0.17", "tokio", "tokio-util", diff --git a/cmd/ethrex/Cargo.toml b/cmd/ethrex/Cargo.toml index 958313131dc..5092c49cc48 100644 --- a/cmd/ethrex/Cargo.toml +++ b/cmd/ethrex/Cargo.toml @@ -52,6 +52,9 @@ thiserror.workspace = true itertools = "0.14.0" url.workspace = true +spawned-rt.workspace = true +spawned-concurrency.workspace = true + # L2 external dependencies tui-logger = { workspace = true, optional = true } diff --git a/cmd/ethrex/initializers.rs b/cmd/ethrex/initializers.rs index 8fa4abfd586..4d48cd52d71 100644 --- a/cmd/ethrex/initializers.rs +++ b/cmd/ethrex/initializers.rs @@ -11,8 +11,7 @@ use ethrex_common::types::Genesis; use ethrex_config::networks::Network; use ethrex_metrics::profiling::{FunctionProfilingLayer, initialize_block_processing_profile}; -#[cfg(feature = "l2")] -use ethrex_p2p::rlpx::l2::l2_connection::P2PBasedContext; +use ethrex_p2p::rlpx::initiator::RLPxInitiator; use ethrex_p2p::{ discv4::peer_table::PeerTable, network::P2PContext, @@ -193,13 +192,10 @@ pub async fn init_network( opts: &Options, network: &Network, datadir: &Path, - local_p2p_node: Node, - signer: SecretKey, peer_handler: PeerHandler, - store: Store, tracker: TaskTracker, blockchain: Arc, - #[cfg(feature = "l2")] based_context: Option, + context: P2PContext, ) { if opts.dev { error!("Binary wasn't built with The feature flag `dev` enabled."); @@ -210,26 +206,6 @@ pub async fn init_network( let bootnodes = get_bootnodes(opts, network, datadir); - #[cfg(feature = "l2")] - let based_context_arg = based_context; - - #[cfg(not(feature = "l2"))] - let based_context_arg = None; - - let context = P2PContext::new( - local_p2p_node, - tracker.clone(), - signer, - peer_handler.peer_table.clone(), - store, - blockchain.clone(), - get_client_version(), - based_context_arg, - opts.tx_broadcasting_time_interval, - ) - .await - .expect("P2P context could not be created"); - ethrex_p2p::start_network(context, bootnodes) .await .expect("Network starts"); @@ -439,17 +415,35 @@ pub async fn init_l1( let local_node_record = get_local_node_record(datadir, &local_p2p_node, &signer); - let peer_handler = PeerHandler::new(PeerTable::spawn(opts.target_peers)); + let peer_table = PeerTable::spawn(opts.target_peers); // TODO: Check every module starts properly. let tracker = TaskTracker::new(); let cancel_token = tokio_util::sync::CancellationToken::new(); + let p2p_context = P2PContext::new( + local_p2p_node.clone(), + tracker.clone(), + signer, + peer_table.clone(), + store.clone(), + blockchain.clone(), + get_client_version(), + None, + opts.tx_broadcasting_time_interval, + ) + .await + .expect("P2P context could not be created"); + + let initiator = RLPxInitiator::spawn(p2p_context.clone()).await; + + let peer_handler = PeerHandler::new(peer_table.clone(), initiator); + init_rpc_api( &opts, peer_handler.clone(), - local_p2p_node.clone(), + local_p2p_node, local_node_record.clone(), store.clone(), blockchain.clone(), @@ -471,14 +465,10 @@ pub async fn init_l1( &opts, &network, datadir, - local_p2p_node, - signer, peer_handler.clone(), - store.clone(), tracker.clone(), blockchain.clone(), - #[cfg(feature = "l2")] - None, + p2p_context, ) .await; } else { diff --git a/cmd/ethrex/l2/initializers.rs b/cmd/ethrex/l2/initializers.rs index 4d67425dd09..77e5e9b1211 100644 --- a/cmd/ethrex/l2/initializers.rs +++ b/cmd/ethrex/l2/initializers.rs @@ -15,8 +15,9 @@ use ethrex_l2::SequencerConfig; use ethrex_l2::sequencer::l1_committer::regenerate_head_state; use ethrex_p2p::{ discv4::peer_table::PeerTable, + network::P2PContext, peer_handler::PeerHandler, - rlpx::l2::l2_connection::P2PBasedContext, + rlpx::{initiator::RLPxInitiator, l2::l2_connection::P2PBasedContext}, sync_manager::SyncManager, types::{Node, NodeRecord}, }; @@ -35,7 +36,7 @@ use url::Url; async fn init_rpc_api( opts: &L1Options, l2_opts: &L2Options, - peer_table: PeerTable, + peer_handler: PeerHandler, local_p2p_node: Node, local_node_record: NodeRecord, store: Store, @@ -46,8 +47,6 @@ async fn init_rpc_api( log_filter_handler: Option>, gas_ceil: Option, ) { - let peer_handler = PeerHandler::new(peer_table); - init_datadir(&opts.datadir); // Create SyncManager @@ -198,18 +197,51 @@ pub async fn init_l2( let local_node_record = get_local_node_record(&datadir, &local_p2p_node, &signer); - let peer_handler = PeerHandler::new(PeerTable::spawn(opts.node_opts.target_peers)); + let peer_table = PeerTable::spawn(opts.node_opts.target_peers); // TODO: Check every module starts properly. let tracker = TaskTracker::new(); let mut join_set = JoinSet::new(); + let p2p_context = P2PContext::new( + local_p2p_node.clone(), + tracker.clone(), + signer, + peer_table.clone(), + store.clone(), + blockchain.clone(), + get_client_version(), + #[cfg(feature = "l2")] + Some(P2PBasedContext { + store_rollup: rollup_store.clone(), + // TODO: The Web3Signer refactor introduced a limitation where the committer key cannot be accessed directly because the signer could be either Local or Remote. + // The Signer enum cannot be used in the P2PBasedContext struct due to cyclic dependencies between the l2-rpc and p2p crates. + // As a temporary solution, a dummy committer key is used until a proper mechanism to utilize the Signer enum is implemented. + // This should be replaced with the Signer enum once the refactor is complete. + committer_key: Arc::new( + SecretKey::from_slice( + &hex::decode( + "385c546456b6a603a1cfcaa9ec9494ba4832da08dd6bcf4de9a71e4a01b74924", + ) + .expect("Invalid committer key"), + ) + .expect("Failed to create committer key"), + ), + }), + opts.node_opts.tx_broadcasting_time_interval, + ) + .await + .expect("P2P context could not be created"); + + let initiator = RLPxInitiator::spawn(p2p_context.clone()).await; + let peer_handler = PeerHandler::new(PeerTable::spawn(opts.node_opts.target_peers), initiator); + let cancel_token = tokio_util::sync::CancellationToken::new(); init_rpc_api( &opts.node_opts, &opts, - peer_handler.peer_table.clone(), + peer_handler.clone(), local_p2p_node.clone(), local_node_record.clone(), store.clone(), @@ -241,28 +273,10 @@ pub async fn init_l2( &opts.node_opts, &network, &datadir, - local_p2p_node, - signer, peer_handler.clone(), - store.clone(), tracker, blockchain.clone(), - Some(P2PBasedContext { - store_rollup: rollup_store.clone(), - // TODO: The Web3Signer refactor introduced a limitation where the committer key cannot be accessed directly because the signer could be either Local or Remote. - // The Signer enum cannot be used in the P2PBasedContext struct due to cyclic dependencies between the l2-rpc and p2p crates. - // As a temporary solution, a dummy committer key is used until a proper mechanism to utilize the Signer enum is implemented. - // This should be replaced with the Signer enum once the refactor is complete. - committer_key: Arc::new( - SecretKey::from_slice( - &hex::decode( - "385c546456b6a603a1cfcaa9ec9494ba4832da08dd6bcf4de9a71e4a01b74924", - ) - .expect("Invalid committer key"), - ) - .expect("Failed to create committer key"), - ), - }), + p2p_context, ) .await; } else { diff --git a/crates/networking/p2p/Cargo.toml b/crates/networking/p2p/Cargo.toml index ce819dc570d..f321678fff8 100644 --- a/crates/networking/p2p/Cargo.toml +++ b/crates/networking/p2p/Cargo.toml @@ -62,6 +62,7 @@ default = ["c-kzg"] c-kzg = ["ethrex-blockchain/c-kzg", "ethrex-common/c-kzg"] sync-test = [] l2 = ["dep:ethrex-storage-rollup"] +test-utils = [] [lints.clippy] unwrap_used = "deny" diff --git a/crates/networking/p2p/network.rs b/crates/networking/p2p/network.rs index 2190c4f0da2..77d2d7ab4eb 100644 --- a/crates/networking/p2p/network.rs +++ b/crates/networking/p2p/network.rs @@ -11,7 +11,6 @@ use crate::{ metrics::METRICS, rlpx::{ connection::server::{PeerConnBroadcastSender, PeerConnection}, - initiator::RLPxInitiator, message::Message, p2p::SUPPORTED_SNAP_CAPABILITIES, }, @@ -94,6 +93,36 @@ impl P2PContext { tx_broadcaster, }) } + + #[cfg(any(test, feature = "test-utils"))] + /// Creates a dummy P2PContext for tests + /// This should only be used in tests as it won't be able to connect to the p2p network + pub async fn dummy(peer_table: PeerTable) -> P2PContext { + use ethrex_storage::EngineType; + + let storage = Store::new("./temp", EngineType::InMemory).expect("Failed to create Store"); + let blockchain: Arc = Arc::new(Blockchain::default_with_store(storage.clone())); + let local_node = Node::from_enode_url( + "enode://d860a01f9722d78051619d1e2351aba3f43f943f6f00718d1b9baa4101932a1f5011f16bb2b1bb35db20d6fe28fa0bf09636d26a87d31de9ec6203eeedb1f666@18.138.108.67:30303", + ).expect("Bad enode url"); + let (channel_broadcast_send_end, _) = + tokio::sync::broadcast::channel::<(tokio::task::Id, Arc)>(100000); + P2PContext { + tracker: TaskTracker::default(), + signer: SecretKey::from_byte_array(&[0xcd; 32]).expect("32 bytes, within curve order"), + table: peer_table.clone(), + storage, + blockchain: blockchain.clone(), + broadcast: channel_broadcast_send_end, + local_node: local_node.clone(), + client_version: "".to_string(), + #[cfg(feature = "l2")] + based_context: None, + tx_broadcaster: TxBroadcaster::spawn(peer_table.clone(), blockchain, 1000) + .await + .expect("Failed to spawn tx broadcaster"), + } + } } #[derive(Debug, thiserror::Error)] @@ -123,8 +152,6 @@ pub async fn start_network(context: P2PContext, bootnodes: Vec) -> Result< error!("Failed to start discovery server: {e}"); })?; - RLPxInitiator::spawn(context.clone()).await; - context.tracker.spawn(serve_p2p_requests(context.clone())); Ok(()) diff --git a/crates/networking/p2p/peer_handler.rs b/crates/networking/p2p/peer_handler.rs index a57f6623b91..e06cd61a29b 100644 --- a/crates/networking/p2p/peer_handler.rs +++ b/crates/networking/p2p/peer_handler.rs @@ -1,5 +1,8 @@ +#[cfg(any(test, feature = "test-utils"))] +use crate::discv4::peer_table::TARGET_PEERS; +use crate::rlpx::initiator::RLPxInitiator; use crate::{ - discv4::peer_table::{PeerData, PeerTable, PeerTableError, TARGET_PEERS}, + discv4::peer_table::{PeerData, PeerTable, PeerTableError}, metrics::{CurrentStepValue, METRICS}, rlpx::{ connection::server::PeerConnection, @@ -34,6 +37,7 @@ use ethrex_rlp::{decode::RLPDecode, encode::RLPEncode}; use ethrex_storage::Store; use ethrex_trie::Nibbles; use ethrex_trie::{Node, verify_range}; +use spawned_concurrency::tasks::GenServerHandle; use std::{ collections::{BTreeMap, HashMap, HashSet, VecDeque}, io::ErrorKind, @@ -68,6 +72,7 @@ pub const MAX_BLOCK_BODIES_TO_REQUEST: usize = 128; #[derive(Debug, Clone)] pub struct PeerHandler { pub peer_table: PeerTable, + pub initiator: GenServerHandle, } pub enum BlockRequestOrder { @@ -142,14 +147,19 @@ async fn ask_peer_head_number( } impl PeerHandler { - pub fn new(peer_table: PeerTable) -> PeerHandler { - Self { peer_table } + pub fn new(peer_table: PeerTable, initiator: GenServerHandle) -> PeerHandler { + Self { + peer_table, + initiator, + } } + #[cfg(any(test, feature = "test-utils"))] /// Creates a dummy PeerHandler for tests where interacting with peers is not needed /// This should only be used in tests as it won't be able to interact with the node's connected peers - pub fn dummy() -> PeerHandler { - PeerHandler::new(PeerTable::spawn(TARGET_PEERS)) + pub async fn dummy() -> PeerHandler { + let peer_table = PeerTable::spawn(TARGET_PEERS); + PeerHandler::new(peer_table.clone(), RLPxInitiator::dummy(peer_table).await) } async fn make_request( diff --git a/crates/networking/p2p/rlpx/initiator.rs b/crates/networking/p2p/rlpx/initiator.rs index 0e291d93fd7..9ef6366e0c2 100644 --- a/crates/networking/p2p/rlpx/initiator.rs +++ b/crates/networking/p2p/rlpx/initiator.rs @@ -1,3 +1,4 @@ +use crate::types::Node; use crate::{ discv4::{ peer_table::PeerTableError, @@ -14,6 +15,9 @@ use spawned_concurrency::{ use std::time::Duration; use tracing::{debug, error, info}; +#[cfg(any(test, feature = "test-utils"))] +use crate::discv4::peer_table::PeerTable; + #[derive(Debug, thiserror::Error)] pub enum RLPxInitiatorError { #[error(transparent)] @@ -44,11 +48,12 @@ impl RLPxInitiator { } } - pub async fn spawn(context: P2PContext) { + pub async fn spawn(context: P2PContext) -> GenServerHandle { info!("Starting RLPx Initiator"); let state = RLPxInitiator::new(context); let mut server = RLPxInitiator::start(state.clone()); let _ = server.cast(InMessage::LookForPeer).await; + server } async fn look_for_peer(&mut self) -> Result<(), RLPxInitiatorError> { @@ -73,11 +78,21 @@ impl RLPxInitiator { self.lookup_interval } } + + #[cfg(any(test, feature = "test-utils"))] + /// Creates a dummy GenServer for tests + /// This should only be used in tests + pub async fn dummy(peer_table: PeerTable) -> GenServerHandle { + info!("Starting RLPx Initiator"); + let state = RLPxInitiator::new(P2PContext::dummy(peer_table).await); + RLPxInitiator::start_on_thread(state) + } } #[derive(Debug, Clone)] pub enum InMessage { LookForPeer, + Initiate { node: Node }, Shutdown, } @@ -117,6 +132,11 @@ impl GenServer for RLPxInitiator { CastResponse::NoReply } + Self::CastMsg::Initiate { node } => { + PeerConnection::spawn_as_initiator(self.context.clone(), &node).await; + METRICS.record_new_rlpx_conn_attempt().await; + CastResponse::NoReply + } Self::CastMsg::Shutdown => CastResponse::Stop, } } diff --git a/crates/networking/p2p/sync.rs b/crates/networking/p2p/sync.rs index 37bf6fdeb35..fd1c25012ab 100644 --- a/crates/networking/p2p/sync.rs +++ b/crates/networking/p2p/sync.rs @@ -24,7 +24,9 @@ use ethrex_common::{ types::{AccountState, Block, BlockHash, BlockHeader}, }; use ethrex_rlp::{decode::RLPDecode, encode::RLPEncode, error::RLPDecodeError}; -use ethrex_storage::{EngineType, STATE_TRIE_SEGMENTS, Store, error::StoreError}; +#[cfg(any(test, feature = "test-utils"))] +use ethrex_storage::EngineType; +use ethrex_storage::{STATE_TRIE_SEGMENTS, Store, error::StoreError}; use ethrex_trie::trie_sorted::TrieGenerationError; use ethrex_trie::{Trie, TrieError}; use rayon::iter::{ParallelBridge, ParallelIterator}; @@ -120,12 +122,13 @@ impl Syncer { } } + #[cfg(any(test, feature = "test-utils"))] /// Creates a dummy Syncer for tests where syncing is not needed /// This should only be used in tests as it won't be able to connect to the p2p network - pub fn dummy() -> Self { + pub async fn dummy() -> Self { Self { snap_enabled: Arc::new(AtomicBool::new(false)), - peers: PeerHandler::dummy(), + peers: PeerHandler::dummy().await, // This won't be used cancel_token: CancellationToken::new(), blockchain: Arc::new(Blockchain::default_with_store( diff --git a/crates/networking/p2p/sync_manager.rs b/crates/networking/p2p/sync_manager.rs index 314bb5230d8..45aa4e1c685 100644 --- a/crates/networking/p2p/sync_manager.rs +++ b/crates/networking/p2p/sync_manager.rs @@ -67,12 +67,13 @@ impl SyncManager { sync_manager } + #[cfg(any(test, feature = "test-utils"))] /// Creates a dummy SyncManager for tests where syncing is not needed /// This should only be used in tests as it won't be able to connect to the p2p network - pub fn dummy() -> Self { + pub async fn dummy() -> Self { Self { snap_enabled: Arc::new(AtomicBool::new(false)), - syncer: Arc::new(Mutex::new(Syncer::dummy())), + syncer: Arc::new(Mutex::new(Syncer::dummy().await)), last_fcu_head: Arc::new(Mutex::new(H256::zero())), store: Store::new("temp.db", ethrex_storage::EngineType::InMemory) .expect("Failed to start Storage Engine"), diff --git a/crates/networking/rpc/Cargo.toml b/crates/networking/rpc/Cargo.toml index 513016d088e..ad9729aa08a 100644 --- a/crates/networking/rpc/Cargo.toml +++ b/crates/networking/rpc/Cargo.toml @@ -20,7 +20,7 @@ ethrex-common.workspace = true ethrex-storage.workspace = true ethrex-vm.workspace = true ethrex-blockchain.workspace = true -ethrex-p2p.workspace = true +ethrex-p2p = {workspace = true, features = ["test-utils"]} ethrex-rlp.workspace = true ethrex-trie.workspace = true ethrex-storage-rollup = { workspace = true, optional = true } @@ -35,6 +35,8 @@ reqwest.workspace = true sha3 = "0.10.8" sha2.workspace = true jemalloc_pprof = { version = "0.8.0", optional = true, features = ["flamegraph", "symbolize"] } +spawned-rt.workspace = true +spawned-concurrency.workspace = true # Clients envy = "0.4.2" diff --git a/crates/networking/rpc/admin/mod.rs b/crates/networking/rpc/admin/mod.rs index 5f1cc5e45e9..408a0fee43c 100644 --- a/crates/networking/rpc/admin/mod.rs +++ b/crates/networking/rpc/admin/mod.rs @@ -10,7 +10,7 @@ use crate::{ utils::{RpcErr, RpcRequest}, }; mod peers; -pub use peers::peers; +pub use peers::{add_peer, peers}; #[derive(Serialize, Debug)] struct NodeInfo { diff --git a/crates/networking/rpc/admin/peers.rs b/crates/networking/rpc/admin/peers.rs index 3e74f0bfc21..f125f69e621 100644 --- a/crates/networking/rpc/admin/peers.rs +++ b/crates/networking/rpc/admin/peers.rs @@ -1,9 +1,16 @@ +use crate::utils::RpcRequest; use crate::{rpc::RpcApiContext, utils::RpcErr}; use core::net::SocketAddr; use ethrex_common::H256; -use ethrex_p2p::{discv4::peer_table::PeerData, rlpx::p2p::Capability}; +use ethrex_p2p::{ + discv4::peer_table::PeerData, + peer_handler::PeerHandler, + rlpx::{initiator::InMessage, p2p::Capability}, + types::Node, +}; use serde::Serialize; use serde_json::Value; +use tokio::time::{Duration, Instant}; /// Serializable peer data returned by the node's rpc #[derive(Serialize)] @@ -89,6 +96,57 @@ pub async fn peers(context: &mut RpcApiContext) -> Result { Ok(serde_json::to_value(peers)?) } +fn parse(request: &RpcRequest) -> Result { + let params = request + .params + .clone() + .ok_or(RpcErr::MissingParam("enode url".to_string()))?; + + if params.len() != 1 { + return Err(RpcErr::BadParams("Expected 1 param".to_owned())); + }; + + let url = params + .first() + .ok_or(RpcErr::MissingParam("enode url".to_string()))? + .as_str() + .ok_or(RpcErr::WrongParam("Expected string".to_string()))?; + + Node::from_enode_url(url).map_err(|error| RpcErr::BadParams(error.to_string())) +} + +pub async fn add_peer(context: &mut RpcApiContext, request: &RpcRequest) -> Result { + let mut server = context.peer_handler.initiator.clone(); + let node = parse(request)?; + + let start = Instant::now(); + let runtime = Duration::from_secs(10); + + let cast_result = server + .cast(InMessage::Initiate { node: node.clone() }) + .await; + // This loop is necessary because connections are asynchronous, so to check if the connection with the peer was actually + // established we need to wait. + loop { + if peer_is_connected(&mut context.peer_handler, &node.enode_url()).await { + return Ok(serde_json::to_value(true)?); + } + + if cast_result.is_err() || start.elapsed() >= runtime { + return Ok(serde_json::to_value(false)?); + } + let _ = tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +async fn peer_is_connected(peer_handler: &mut PeerHandler, enode_url: &str) -> bool { + peer_handler + .read_connected_peers() + .await + .iter() + .any(|peer| peer.node.enode_url() == *enode_url) +} + // TODO: Adapt the test to the new P2P architecture. #[cfg(test)] mod tests { diff --git a/crates/networking/rpc/rpc.rs b/crates/networking/rpc/rpc.rs index 4db2a42df10..d8af37c08d1 100644 --- a/crates/networking/rpc/rpc.rs +++ b/crates/networking/rpc/rpc.rs @@ -555,6 +555,7 @@ pub async fn map_admin_requests( "admin_nodeInfo" => admin::node_info(context.storage, &context.node_data), "admin_peers" => admin::peers(&mut context).await, "admin_setLogLevel" => admin::set_log_level(req, &context.log_filter_handler).await, + "admin_addPeer" => admin::add_peer(&mut context, req).await, unknown_admin_method => Err(RpcErr::MethodNotFound(unknown_admin_method.to_owned())), } } diff --git a/crates/networking/rpc/utils.rs b/crates/networking/rpc/utils.rs index ec39d9efd90..737152ac153 100644 --- a/crates/networking/rpc/utils.rs +++ b/crates/networking/rpc/utils.rs @@ -394,8 +394,8 @@ pub mod test_utils { jwt_secret, local_p2p_node, local_node_record, - SyncManager::dummy(), - PeerHandler::dummy(), + SyncManager::dummy().await, + PeerHandler::dummy().await, "ethrex/test".to_string(), None, DEFAULT_BUILDER_GAS_CEIL, @@ -414,8 +414,8 @@ pub mod test_utils { storage, blockchain, active_filters: Default::default(), - syncer: Arc::new(SyncManager::dummy()), - peer_handler: PeerHandler::dummy(), + syncer: Arc::new(SyncManager::dummy().await), + peer_handler: PeerHandler::dummy().await, node_data: NodeData { jwt_secret: Default::default(), local_p2p_node: example_p2p_node(), diff --git a/tooling/sync/Makefile b/tooling/sync/Makefile index 329ea0cc056..8d7980d952a 100644 --- a/tooling/sync/Makefile +++ b/tooling/sync/Makefile @@ -182,7 +182,7 @@ start-ethrex: ## Start ethrex for the network given by NETWORK. --metrics.port 3701 \ --network $(NETWORK) \ --datadir "$(DATA_PATH)/${NETWORK}_data/ethrex/$(EVM)" \ - --authrpc.jwtsecret $(DATA_PATH)/${NETWORK}_data/jwt.hex \ + --authrpc.jwtsecret "$(DATA_PATH)/${NETWORK}_data/jwt.hex" \ $(BOOTNODES_FLAG) \ SERVER_SYNC_BRANCH ?= main