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 receipt_extractor = ReceiptExtractor::new(runtime_api_provider.clone()).await?;
293		let max_variable_number = sqlite_db_query_max_variable_number(&pool).await;
294		let db_ctx = DbContext::new(pool, max_variable_number);
295
296		let receipt_provider = ReceiptProvider::new(
297			db_ctx,
298			block_provider.clone(),
299			receipt_extractor.clone(),
300			keep_latest_n_blocks,
301		)
302		.await?;
303
304		let client = Client::new(
305			api,
306			rpc_client,
307			rpc,
308			block_provider,
309			receipt_provider,
310			eth_pruning.is_archive(),
311			subscription_gap_queue,
312			runtime_api_provider,
313		)
314		.await?;
315
316		Ok(client)
317	}
318	.fuse();
319	pin_mut!(fut);
320
321	match tokio_handle.block_on(abort_signal.try_until_signal(fut)) {
322		Ok(Ok(client)) => Ok(client),
323		Ok(Err(err)) => Err(err),
324		Err(_) => anyhow::bail!("Process interrupted"),
325	}
326}
327
328/// Start the JSON-RPC server using the given command line arguments.
329pub fn run(cmd: CliCommand) -> anyhow::Result<()> {
330	let CliCommand {
331		rpc_params,
332		prometheus_params,
333		node_rpc_url,
334		eth_pruning,
335		shared_params,
336		allow_unprotected_txs,
337		..
338	} = cmd;
339
340	#[cfg(not(test))]
341	init_logger(&shared_params)?;
342	let is_dev = shared_params.dev;
343	let explicit_base_path = shared_params.base_path.is_some();
344	let base_path = shared_params.base_path()?;
345
346	if is_dev && eth_pruning.is_archive() && !explicit_base_path {
347		log::warn!(
348			target: LOG_TARGET,
349			"โš ๏ธ  Running in --dev mode with --eth-pruning=archive but no --base-path. \
350			 The database will be stored in a temporary directory and lost on exit. \
351			 Use --base-path to persist the database."
352		);
353	}
354
355	let db_options = resolve_db_options(eth_pruning, base_path)?;
356
357	let rpc_addrs: Option<Vec<sc_service::config::RpcEndpoint>> = rpc_params
358		.rpc_addr(is_dev, false, DEFAULT_RPC_PORT)?
359		.map(|addrs| addrs.into_iter().map(Into::into).collect());
360
361	let rpc_config = RpcConfiguration {
362		addr: rpc_addrs,
363		methods: rpc_params.rpc_methods.into(),
364		max_connections: rpc_params.rpc_max_connections,
365		cors: rpc_params.rpc_cors(is_dev)?,
366		max_request_size: rpc_params.rpc_max_request_size,
367		max_response_size: rpc_params.rpc_max_response_size,
368		id_provider: None,
369		max_subs_per_conn: rpc_params.rpc_max_subscriptions_per_connection,
370		port: rpc_params.rpc_port.unwrap_or(DEFAULT_RPC_PORT),
371		message_buffer_capacity: rpc_params.rpc_message_buffer_capacity_per_connection,
372		batch_config: rpc_params.rpc_batch_config()?,
373		rate_limit: rpc_params.rpc_rate_limit,
374		rate_limit_whitelisted_ips: rpc_params.rpc_rate_limit_whitelisted_ips,
375		rate_limit_trust_proxy_headers: rpc_params.rpc_rate_limit_trust_proxy_headers,
376		request_logger_limit: if is_dev { 1024 * 1024 } else { 1024 },
377	};
378
379	let prometheus_config =
380		prometheus_params.prometheus_config(DEFAULT_PROMETHEUS_PORT, "eth-rpc".into());
381	let prometheus_registry = prometheus_config.as_ref().map(|config| &config.registry);
382
383	let tokio_runtime = sc_cli::build_runtime()?;
384	let tokio_handle = tokio_runtime.handle();
385	let mut task_manager = TaskManager::new(tokio_handle.clone(), prometheus_registry)?;
386
387	let (subscription_gap_queue, gap_fill_rx) = SubscriptionGapQueue::new();
388	let client = build_client(
389		tokio_handle,
390		eth_pruning,
391		&node_rpc_url,
392		db_options,
393		rpc_config.max_request_size * 1024 * 1024,
394		rpc_config.max_response_size * 1024 * 1024,
395		tokio_runtime.block_on(async { Signals::capture() })?,
396		subscription_gap_queue,
397	)?;
398
399	// Prometheus metrics.
400	if let Some(PrometheusConfig { port, registry }) = prometheus_config.clone() {
401		task_manager.spawn_handle().spawn(
402			"prometheus-endpoint",
403			None,
404			prometheus_endpoint::init_prometheus(port, registry).map(drop),
405		);
406	}
407
408	let rpc_runtime = create_rpc_runtime(rpc_config.max_connections)
409		.map_err(|e| anyhow::anyhow!("Failed to create RPC runtime: {}", e))?;
410
411	let rpc_api = rpc_module(is_dev, client.clone(), allow_unprotected_txs)?;
412	let rpc_server_handle = start_rpc_servers(
413		&rpc_config,
414		prometheus_registry,
415		tokio_handle,
416		rpc_api,
417		rpc_runtime,
418		None,
419	)?;
420
421	task_manager
422		.spawn_essential_handle()
423		.spawn("block-subscription", None, async move {
424			let mut futures: Vec<BoxFuture<'_, Result<(), _>>> = vec![
425				Box::pin(client.subscribe_and_cache_new_blocks(SubscriptionType::BestBlocks)),
426				Box::pin(client.subscribe_and_cache_new_blocks(SubscriptionType::FinalizedBlocks)),
427			];
428
429			if eth_pruning.is_archive() {
430				futures.push(Box::pin(client.sync_backward()));
431			}
432
433			// Backfill gaps caused by subscription reconnects.
434			futures.push(Box::pin(async {
435				client.run_subscription_gap_filler(gap_fill_rx).await;
436				Ok::<_, ClientError>(())
437			}));
438
439			if let Err(err) = futures::future::try_join_all(futures).await {
440				panic!("Block subscription task failed: {err:?}",)
441			}
442		});
443
444	task_manager.keep_alive(rpc_server_handle);
445	let signals = tokio_runtime.block_on(async { Signals::capture() })?;
446	tokio_runtime.block_on(signals.run_until_signal(task_manager.future().fuse()))?;
447	Ok(())
448}
449
450/// Create the JSON-RPC module.
451fn rpc_module(
452	is_dev: bool,
453	client: Client,
454	allow_unprotected_txs: bool,
455) -> Result<RpcModule<()>, sc_service::Error> {
456	let eth_api = EthRpcServerImpl::new(client.clone())
457		.with_accounts(if is_dev {
458			vec![
459				crate::Account::from(subxt_signer::eth::dev::alith()),
460				crate::Account::from(subxt_signer::eth::dev::baltathar()),
461				crate::Account::from(subxt_signer::eth::dev::charleth()),
462				crate::Account::from(subxt_signer::eth::dev::dorothy()),
463				crate::Account::from(subxt_signer::eth::dev::ethan()),
464			]
465		} else {
466			vec![]
467		})
468		.with_allow_unprotected_txs(allow_unprotected_txs)
469		.with_use_pending_for_estimate_gas(is_dev)
470		.into_rpc();
471
472	let health_api = SystemHealthRpcServerImpl::new(client.clone()).into_rpc();
473	let debug_api = DebugRpcServerImpl::new(client.clone()).into_rpc();
474	let polkadot_api = PolkadotRpcServerImpl::new(client).into_rpc();
475
476	let mut module = RpcModule::new(());
477	module.merge(eth_api).map_err(|e| sc_service::Error::Application(e.into()))?;
478	module.merge(health_api).map_err(|e| sc_service::Error::Application(e.into()))?;
479	module.merge(debug_api).map_err(|e| sc_service::Error::Application(e.into()))?;
480	module
481		.merge(polkadot_api)
482		.map_err(|e| sc_service::Error::Application(e.into()))?;
483	Ok(module)
484}
485
486#[cfg(test)]
487mod tests {
488	use super::*;
489	use tempfile::TempDir;
490
491	#[test]
492	fn in_memory_returns_memory_options() {
493		let opts = resolve_db_options(EthPruningMode::KeepLatest(256), None).unwrap();
494		// In-memory options produce `:memory:` filename.
495		let filename = opts.get_filename();
496		assert_eq!(filename, std::path::Path::new(":memory:"));
497	}
498
499	#[test]
500	fn persistent_with_explicit_base_path() {
501		let tmp = TempDir::new().unwrap();
502		let base = BasePath::new(tmp.path());
503		let opts = resolve_db_options(EthPruningMode::Archive, Some(base)).unwrap();
504		assert_eq!(opts.get_filename(), tmp.path().join(DEFAULT_DATABASE_NAME));
505		assert!(tmp.path().exists());
506	}
507
508	#[test]
509	fn persistent_default_path() {
510		let opts = resolve_db_options(EthPruningMode::Archive, None).unwrap();
511		let filename = opts.get_filename().to_string_lossy().to_string();
512		assert!(filename.contains("eth-rpc"));
513		assert!(filename.contains(DEFAULT_DATABASE_NAME));
514	}
515
516	#[test]
517	fn persistent_creates_nested_directories() {
518		let tmp = TempDir::new().unwrap();
519		let nested = tmp.path().join("a").join("b");
520		let base = BasePath::new(&nested);
521		resolve_db_options(EthPruningMode::Archive, Some(base)).unwrap();
522		assert!(nested.exists());
523	}
524
525	#[test]
526	fn eth_pruning_mode() {
527		// CLI parsing
528		let cmd = CliCommand::try_parse_from(["eth-rpc", "--eth-pruning", "archive"]).unwrap();
529		assert_eq!(cmd.eth_pruning, EthPruningMode::Archive);
530
531		let cmd = CliCommand::try_parse_from(["eth-rpc", "--eth-pruning", "256"]).unwrap();
532		assert_eq!(cmd.eth_pruning, EthPruningMode::KeepLatest(256));
533
534		// Default is archive
535		let cmd = CliCommand::try_parse_from(["eth-rpc"]).unwrap();
536		assert_eq!(cmd.eth_pruning, EthPruningMode::Archive);
537	}
538}