referrerpolicy=no-referrer-when-downgrade

pallet_revive_eth_rpc/
cli.rs

1// This file is part of Substrate.
2
3// Copyright (C) Parity Technologies (UK) Ltd.
4// SPDX-License-Identifier: Apache-2.0
5
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10// 	http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17//! The Ethereum JSON-RPC server.
18use crate::{
19	DbContext, DebugRpcServer, DebugRpcServerImpl, EthRpcServer, EthRpcServerImpl, LOG_TARGET,
20	PolkadotRpcServer, PolkadotRpcServerImpl, ReceiptExtractor, ReceiptProvider,
21	SubxtBlockInfoProvider, SystemHealthRpcServer, SystemHealthRpcServerImpl,
22	client::{
23		Client, ClientError, SubscriptionGapQueue, SubscriptionType, connect,
24		version_aware_runtime_api::VersionAwareRuntimeApiProvider,
25	},
26};
27use clap::{CommandFactory, FromArgMatches, Parser};
28use futures::{FutureExt, future::BoxFuture, pin_mut};
29use jsonrpsee::server::RpcModule;
30use sc_cli::{PrometheusParams, RpcParams, SharedParams, Signals};
31use sc_service::{
32	TaskManager,
33	config::{BasePath, PrometheusConfig, RpcConfiguration},
34	create_rpc_runtime, start_rpc_servers,
35};
36use sqlx::{
37	SqlitePool,
38	sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
39};
40use std::path::PathBuf;
41
42/// Query the maximum number of bound parameters SQLite allows per query
43async fn sqlite_db_query_max_variable_number(pool: &SqlitePool) -> usize {
44	let limit = async {
45		let mut conn = pool
46			.acquire()
47			.await
48			.inspect_err(|e| log::warn!(target: LOG_TARGET, "๐Ÿ’พ Failed to acquire connection: {e}"))
49			.ok()?;
50		let mut handle = conn
51			.lock_handle()
52			.await
53			.inspect_err(|e| log::warn!(target: LOG_TARGET, "๐Ÿ’พ Failed to lock handle: {e}"))
54			.ok()?;
55		// SAFETY: `lock_handle` guarantees the raw pointer is valid for
56		// the lifetime of the guard, and passing -1 only queries the limit.
57		let raw = unsafe {
58			libsqlite3_sys::sqlite3_limit(
59				handle.as_raw_handle().as_ptr(),
60				libsqlite3_sys::SQLITE_LIMIT_VARIABLE_NUMBER,
61				-1,
62			)
63		};
64		raw.try_into().ok()
65	}
66	.await;
67
68	let default = DbContext::DEFAULT_MAX_VARIABLE_NUMBER;
69	limit.inspect(|n| log::info!(target: LOG_TARGET, "๐Ÿ’พ SQLite db_query_max_variable_number: {n}"))
70		.unwrap_or_else(|| {
71			log::warn!(target: LOG_TARGET, "๐Ÿ’พ Failed to query SQLite variable limit, falling back to {default}");
72			default
73		})
74}
75
76/// Specifies the eth-rpc pruning mode.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)]
78pub enum EthPruningMode {
79	/// Persistent on-disk database with backward historical sync of all blocks.
80	#[display(fmt = "archive")]
81	Archive,
82	/// In-memory database keeping only the latest N blocks.
83	#[display(fmt = "{_0}")]
84	KeepLatest(usize),
85}
86
87impl EthPruningMode {
88	/// Returns `true` if this mode enables historical block sync.
89	pub fn is_archive(&self) -> bool {
90		matches!(self, Self::Archive)
91	}
92
93	/// Returns the number of blocks to keep, if in `KeepLatest` mode.
94	pub fn keep_latest(&self) -> Option<usize> {
95		match self {
96			Self::KeepLatest(n) => Some(*n),
97			_ => None,
98		}
99	}
100}
101
102impl std::str::FromStr for EthPruningMode {
103	type Err = String;
104
105	fn from_str(input: &str) -> Result<Self, Self::Err> {
106		match input {
107			"archive" => Ok(Self::Archive),
108			n => {
109				n.parse::<usize>()
110					.ok()
111					.filter(|&v| v >= 1)
112					.map(Self::KeepLatest)
113					.ok_or_else(|| {
114						format!(
115							"Invalid pruning mode '{n}': expected 'archive' or a positive integer"
116						)
117					})
118			},
119		}
120	}
121}
122
123// Default port if --prometheus-port is not specified
124const DEFAULT_PROMETHEUS_PORT: u16 = 9616;
125
126// Default port if --rpc-port is not specified
127const DEFAULT_RPC_PORT: u16 = 8545;
128
129const DEFAULT_DATABASE_NAME: &str = "eth-rpc.db";
130
131// Parsed command instructions from the command line
132#[derive(Parser, Debug)]
133#[clap(author, about, version)]
134pub struct CliCommand {
135	/// The node url to connect to
136	#[clap(long, default_value = "ws://127.0.0.1:9944")]
137	pub node_rpc_url: String,
138
139	/// Pruning mode for the eth-rpc receipt database.
140	///
141	/// - archive (default): Sync all historical blocks (requires an archive node).
142	/// - N (>= 1): In-memory database keeping only the latest N blocks.
143	#[clap(long, default_value = "archive")]
144	pub eth_pruning: EthPruningMode,
145
146	#[allow(missing_docs)]
147	#[clap(flatten)]
148	pub shared_params: SharedParams,
149
150	#[allow(missing_docs)]
151	#[clap(flatten)]
152	pub rpc_params: RpcParams,
153
154	#[allow(missing_docs)]
155	#[clap(flatten)]
156	pub prometheus_params: PrometheusParams,
157
158	/// By default, the node rejects any transaction that's unprotected (i.e., that doesn't have a
159	/// chain-id). If the user wishes the submit such a transaction then they can use this flag to
160	/// instruct the RPC to ignore this check.
161	#[arg(long)]
162	pub allow_unprotected_txs: bool,
163}
164
165impl CliCommand {
166	/// Parse CLI args, rejecting any removed flags with a helpful message.
167	pub fn parse_cli() -> anyhow::Result<Self> {
168		let removed_flags =
169			["database-url", "cache-size", "index-last-n-blocks", "earliest-receipt-block"];
170
171		let cmd = removed_flags.iter().fold(Self::command(), |cmd, name| {
172			cmd.arg(
173				clap::Arg::new(*name)
174					.long(*name)
175					.num_args(0..=1)
176					.hide(true)
177					.action(clap::ArgAction::Set),
178			)
179		});
180		let matches = cmd.get_matches();
181
182		let used: Vec<_> = removed_flags
183			.iter()
184			.filter(|f| matches.contains_id(f))
185			.map(|f| format!("--{f}"))
186			.collect();
187		if !used.is_empty() {
188			anyhow::bail!(
189				"[{}] have been removed. \
190				 Check polkadot-sdk PR #11153 for the CLI migration guide.",
191				used.join(", "),
192			);
193		}
194
195		Ok(Self::from_arg_matches(&matches).expect("already validated by clap"))
196	}
197}
198
199/// Initialize the logger
200#[cfg(not(test))]
201fn init_logger(params: &SharedParams) -> anyhow::Result<()> {
202	let mut logger = sc_cli::LoggerBuilder::new(params.log_filters().join(","));
203	logger
204		.with_log_reloading(params.enable_log_reloading)
205		.with_detailed_output(params.detailed_log_output);
206
207	if let Some(tracing_targets) = &params.tracing_targets {
208		let tracing_receiver = params.tracing_receiver.into();
209		logger.with_profiling(tracing_receiver, tracing_targets);
210	}
211
212	if params.disable_log_color {
213		logger.with_colors(false);
214	}
215
216	logger.init()?;
217	Ok(())
218}
219
220/// Resolve the base directory for persistent database storage.
221///
222/// - If `base_path` is `Some` (explicit `--base-path` or `--dev` temp dir), use it directly.
223/// - If `base_path` is `None`, use the platform default:
224///   - macOS: `~/Library/Application Support/eth-rpc/`
225///   - Linux: `~/.local/share/eth-rpc/`
226///   - Windows: `%APPDATA%\eth-rpc\`
227fn resolve_db_dir(base_path: Option<BasePath>) -> PathBuf {
228	match base_path {
229		Some(path) => path.path().to_path_buf(),
230		None => BasePath::from_project("", "", "eth-rpc").path().to_path_buf(),
231	}
232}
233
234/// Resolve SQLite connection options from CLI arguments.
235fn resolve_db_options(
236	eth_pruning: EthPruningMode,
237	base_path: Option<BasePath>,
238) -> anyhow::Result<SqliteConnectOptions> {
239	if eth_pruning.is_archive() {
240		let db_dir = resolve_db_dir(base_path);
241		std::fs::create_dir_all(&db_dir).map_err(|e| {
242			anyhow::anyhow!("Failed to create database directory {}: {e}", db_dir.display())
243		})?;
244		let db_path = db_dir.join(DEFAULT_DATABASE_NAME);
245		log::info!(target: LOG_TARGET, "๐Ÿ’พ Database path: {}", db_path.display());
246		// WAL mode allows concurrent writes from the live subscription
247		// and the backward sync without SQLITE_BUSY errors.
248		Ok(SqliteConnectOptions::new()
249			.filename(&db_path)
250			.create_if_missing(true)
251			.journal_mode(SqliteJournalMode::Wal))
252	} else {
253		Ok(SqliteConnectOptions::new().in_memory(true))
254	}
255}
256
257fn build_client(
258	tokio_handle: &tokio::runtime::Handle,
259	eth_pruning: EthPruningMode,
260	node_rpc_url: &str,
261	db_options: SqliteConnectOptions,
262	max_request_size: u32,
263	max_response_size: u32,
264	abort_signal: Signals,
265	subscription_gap_queue: SubscriptionGapQueue,
266) -> anyhow::Result<Client> {
267	let fut = async {
268		let (api, rpc_client, rpc) =
269			connect(node_rpc_url, max_request_size, max_response_size).await?;
270		let block_provider = SubxtBlockInfoProvider::new(api.clone(), rpc.clone()).await?;
271
272		let (pool, keep_latest_n_blocks) = match eth_pruning {
273			EthPruningMode::Archive => {
274				(SqlitePoolOptions::new().connect_with(db_options).await?, None)
275			},
276			EthPruningMode::KeepLatest(max_blocks) => {
277				log::info!(target: LOG_TARGET,
278					"๐Ÿ’พ Using in-memory database, keeping only {max_blocks} blocks");
279				// see sqlite in-memory issue: https://github.com/transact-rs/sqlx/issues/2510
280				let pool = SqlitePoolOptions::new()
281					.max_connections(1)
282					.idle_timeout(None)
283					.max_lifetime(None)
284					.connect_with(db_options)
285					.await?;
286				(pool, Some(max_blocks))
287			},
288		};
289
290		let runtime_api_provider =
291			VersionAwareRuntimeApiProvider::new(api.clone(), rpc_client.clone());
292		let chain_id = crate::chain_id(&api).await?;
293		let receipt_extractor = ReceiptExtractor::new(runtime_api_provider.clone(), chain_id);
294		let max_variable_number = sqlite_db_query_max_variable_number(&pool).await;
295		let db_ctx = DbContext::new(pool, max_variable_number);
296
297		let receipt_provider = ReceiptProvider::new(
298			db_ctx,
299			block_provider.clone(),
300			receipt_extractor.clone(),
301			keep_latest_n_blocks,
302		)
303		.await?;
304
305		let client = Client::new(
306			api,
307			rpc_client,
308			rpc,
309			block_provider,
310			receipt_provider,
311			eth_pruning.is_archive(),
312			subscription_gap_queue,
313			runtime_api_provider,
314			chain_id,
315		)
316		.await?;
317
318		Ok(client)
319	}
320	.fuse();
321	pin_mut!(fut);
322
323	match tokio_handle.block_on(abort_signal.try_until_signal(fut)) {
324		Ok(Ok(client)) => Ok(client),
325		Ok(Err(err)) => Err(err),
326		Err(_) => anyhow::bail!("Process interrupted"),
327	}
328}
329
330/// Start the JSON-RPC server using the given command line arguments.
331pub fn run(cmd: CliCommand) -> anyhow::Result<()> {
332	let CliCommand {
333		rpc_params,
334		prometheus_params,
335		node_rpc_url,
336		eth_pruning,
337		shared_params,
338		allow_unprotected_txs,
339		..
340	} = cmd;
341
342	#[cfg(not(test))]
343	init_logger(&shared_params)?;
344	let is_dev = shared_params.dev;
345	let explicit_base_path = shared_params.base_path.is_some();
346	let base_path = shared_params.base_path()?;
347
348	if is_dev && eth_pruning.is_archive() && !explicit_base_path {
349		log::warn!(
350			target: LOG_TARGET,
351			"โš ๏ธ  Running in --dev mode with --eth-pruning=archive but no --base-path. \
352			 The database will be stored in a temporary directory and lost on exit. \
353			 Use --base-path to persist the database."
354		);
355	}
356
357	let db_options = resolve_db_options(eth_pruning, base_path)?;
358
359	let rpc_addrs: Option<Vec<sc_service::config::RpcEndpoint>> = rpc_params
360		.rpc_addr(is_dev, false, DEFAULT_RPC_PORT)?
361		.map(|addrs| addrs.into_iter().map(Into::into).collect());
362
363	let rpc_config = RpcConfiguration {
364		addr: rpc_addrs,
365		methods: rpc_params.rpc_methods.into(),
366		max_connections: rpc_params.rpc_max_connections,
367		cors: rpc_params.rpc_cors(is_dev)?,
368		max_request_size: rpc_params.rpc_max_request_size,
369		max_response_size: rpc_params.rpc_max_response_size,
370		id_provider: None,
371		max_subs_per_conn: rpc_params.rpc_max_subscriptions_per_connection,
372		port: rpc_params.rpc_port.unwrap_or(DEFAULT_RPC_PORT),
373		message_buffer_capacity: rpc_params.rpc_message_buffer_capacity_per_connection,
374		batch_config: rpc_params.rpc_batch_config()?,
375		rate_limit: rpc_params.rpc_rate_limit,
376		rate_limit_whitelisted_ips: rpc_params.rpc_rate_limit_whitelisted_ips,
377		rate_limit_trust_proxy_headers: rpc_params.rpc_rate_limit_trust_proxy_headers,
378		request_logger_limit: if is_dev { 1024 * 1024 } else { 1024 },
379	};
380
381	let prometheus_config =
382		prometheus_params.prometheus_config(DEFAULT_PROMETHEUS_PORT, "eth-rpc".into());
383	let prometheus_registry = prometheus_config.as_ref().map(|config| &config.registry);
384
385	let tokio_runtime = sc_cli::build_runtime()?;
386	let tokio_handle = tokio_runtime.handle();
387	let mut task_manager = TaskManager::new(tokio_handle.clone(), prometheus_registry)?;
388
389	let (subscription_gap_queue, gap_fill_rx) = SubscriptionGapQueue::new();
390	let client = build_client(
391		tokio_handle,
392		eth_pruning,
393		&node_rpc_url,
394		db_options,
395		rpc_config.max_request_size * 1024 * 1024,
396		rpc_config.max_response_size * 1024 * 1024,
397		tokio_runtime.block_on(async { Signals::capture() })?,
398		subscription_gap_queue,
399	)?;
400
401	// Prometheus metrics.
402	if let Some(PrometheusConfig { port, registry }) = prometheus_config.clone() {
403		task_manager.spawn_handle().spawn(
404			"prometheus-endpoint",
405			None,
406			prometheus_endpoint::init_prometheus(port, registry).map(drop),
407		);
408	}
409
410	let rpc_runtime = create_rpc_runtime(rpc_config.max_connections)
411		.map_err(|e| anyhow::anyhow!("Failed to create RPC runtime: {}", e))?;
412
413	let rpc_api = rpc_module(is_dev, client.clone(), allow_unprotected_txs)?;
414	let rpc_server_handle = start_rpc_servers(
415		&rpc_config,
416		prometheus_registry,
417		tokio_handle,
418		rpc_api,
419		rpc_runtime,
420		None,
421	)?;
422
423	task_manager
424		.spawn_essential_handle()
425		.spawn("block-subscription", None, async move {
426			let mut futures: Vec<BoxFuture<'_, Result<(), _>>> = vec![
427				Box::pin(client.subscribe_and_cache_new_blocks(SubscriptionType::BestBlocks)),
428				Box::pin(client.subscribe_and_cache_new_blocks(SubscriptionType::FinalizedBlocks)),
429			];
430
431			if eth_pruning.is_archive() {
432				futures.push(Box::pin(client.sync_backward()));
433			}
434
435			// Backfill gaps caused by subscription reconnects.
436			futures.push(Box::pin(async {
437				client.run_subscription_gap_filler(gap_fill_rx).await;
438				Ok::<_, ClientError>(())
439			}));
440
441			if let Err(err) = futures::future::try_join_all(futures).await {
442				panic!("Block subscription task failed: {err:?}",)
443			}
444		});
445
446	task_manager.keep_alive(rpc_server_handle);
447	let signals = tokio_runtime.block_on(async { Signals::capture() })?;
448	tokio_runtime.block_on(signals.run_until_signal(task_manager.future().fuse()))?;
449	Ok(())
450}
451
452/// Create the JSON-RPC module.
453fn rpc_module(
454	is_dev: bool,
455	client: Client,
456	allow_unprotected_txs: bool,
457) -> Result<RpcModule<()>, sc_service::Error> {
458	let eth_api = EthRpcServerImpl::new(client.clone())
459		.with_accounts(if is_dev {
460			vec![
461				crate::Account::from(subxt_signer::eth::dev::alith()),
462				crate::Account::from(subxt_signer::eth::dev::baltathar()),
463				crate::Account::from(subxt_signer::eth::dev::charleth()),
464				crate::Account::from(subxt_signer::eth::dev::dorothy()),
465				crate::Account::from(subxt_signer::eth::dev::ethan()),
466			]
467		} else {
468			vec![]
469		})
470		.with_allow_unprotected_txs(allow_unprotected_txs)
471		.with_use_pending_for_estimate_gas(is_dev)
472		.into_rpc();
473
474	let health_api = SystemHealthRpcServerImpl::new(client.clone()).into_rpc();
475	let debug_api = DebugRpcServerImpl::new(client.clone()).into_rpc();
476	let polkadot_api = PolkadotRpcServerImpl::new(client).into_rpc();
477
478	let mut module = RpcModule::new(());
479	module.merge(eth_api).map_err(|e| sc_service::Error::Application(e.into()))?;
480	module.merge(health_api).map_err(|e| sc_service::Error::Application(e.into()))?;
481	module.merge(debug_api).map_err(|e| sc_service::Error::Application(e.into()))?;
482	module
483		.merge(polkadot_api)
484		.map_err(|e| sc_service::Error::Application(e.into()))?;
485	Ok(module)
486}
487
488#[cfg(test)]
489mod tests {
490	use super::*;
491	use tempfile::TempDir;
492
493	#[test]
494	fn in_memory_returns_memory_options() {
495		let opts = resolve_db_options(EthPruningMode::KeepLatest(256), None).unwrap();
496		// In-memory options produce `:memory:` filename.
497		let filename = opts.get_filename();
498		assert_eq!(filename, std::path::Path::new(":memory:"));
499	}
500
501	#[test]
502	fn persistent_with_explicit_base_path() {
503		let tmp = TempDir::new().unwrap();
504		let base = BasePath::new(tmp.path());
505		let opts = resolve_db_options(EthPruningMode::Archive, Some(base)).unwrap();
506		assert_eq!(opts.get_filename(), tmp.path().join(DEFAULT_DATABASE_NAME));
507		assert!(tmp.path().exists());
508	}
509
510	#[test]
511	fn persistent_default_path() {
512		let opts = resolve_db_options(EthPruningMode::Archive, None).unwrap();
513		let filename = opts.get_filename().to_string_lossy().to_string();
514		assert!(filename.contains("eth-rpc"));
515		assert!(filename.contains(DEFAULT_DATABASE_NAME));
516	}
517
518	#[test]
519	fn persistent_creates_nested_directories() {
520		let tmp = TempDir::new().unwrap();
521		let nested = tmp.path().join("a").join("b");
522		let base = BasePath::new(&nested);
523		resolve_db_options(EthPruningMode::Archive, Some(base)).unwrap();
524		assert!(nested.exists());
525	}
526
527	#[test]
528	fn eth_pruning_mode() {
529		// CLI parsing
530		let cmd = CliCommand::try_parse_from(["eth-rpc", "--eth-pruning", "archive"]).unwrap();
531		assert_eq!(cmd.eth_pruning, EthPruningMode::Archive);
532
533		let cmd = CliCommand::try_parse_from(["eth-rpc", "--eth-pruning", "256"]).unwrap();
534		assert_eq!(cmd.eth_pruning, EthPruningMode::KeepLatest(256));
535
536		// Default is archive
537		let cmd = CliCommand::try_parse_from(["eth-rpc"]).unwrap();
538		assert_eq!(cmd.eth_pruning, EthPruningMode::Archive);
539	}
540}