referrerpolicy=no-referrer-when-downgrade
Expand description

Learn about how to create custom RPC endpoints and runtime APIs.

§Custom RPC do’s and don’ts

TLDR: Don’t create new custom RPCs. Instead, rely on custom Runtime APIs, combined with state_call.

§Background

Polkadot-SDK offers the ability to query and subscribe storages directly. However what it does not have is view functions. This is an essential feature to avoid duplicated logic between runtime and the client SDK. Custom RPC was used as a solution. It allow the RPC node to expose new RPCs that clients can be used to query computed properties.

§Problems with Custom RPC

Unfortunately, custom RPC comes with many problems. To list a few:

  • It is offchain logic executed by the RPC node and therefore the client has to trust the RPC node.
  • To upgrade or add a new RPC logic, the RPC node has to be upgraded. This can cause significant trouble when the RPC infrastructure is decentralized as we will need to coordinate multiple parties to upgrade the RPC nodes.
  • A lot of boilerplate code is required to add custom RPC.
  • It prevents dApps from using a light client or an alternative client.
  • It makes ecosystem tooling integration much more complicated. For example, dApps will not be able to use Chopsticks for testing as Chopsticks will not have the custom RPC implementation.
  • Poorly implemented custom RPC can be a DoS vector.

Hence, we should avoid custom RPC.

§Alternatives

Generally, sc_rpc::state::StateBackend::call aka. state_call should be used instead of custom RPC.

Usually, each custom RPC comes with a corresponding runtime API which implements the business logic. So instead of invoke the custom RPC, we can use state_call to invoke the runtime API directly. This is a trivial change on the dApp and no change on the runtime side. We may remove the custom RPC from the node side if wanted.

There are some other cases that a simple runtime API is not enough. For example, implementation of Ethereum RPC requires an additional offchain database to index transactions. In this particular case, we can have the RPC implemented on another client.

For example, the Acala EVM+ RPC are implemented by eth-rpc-adapter. Alternatively, the Frontier project also provided Ethereum RPC compatibility directly in the node-side software.

§Create a new Runtime API

For example, let’s take a look at the process through which the account nonce can be queried through an RPC. First, a new runtime-api needs to be declared:

sp_api::decl_runtime_apis! {
	/// The API to query account nonce.
	pub trait AccountNonceApi<AccountId, Nonce> where
		AccountId: codec::Codec,
		Nonce: codec::Codec,
	{
		/// Get current account nonce of given `AccountId`.
		fn account_nonce(account: AccountId) -> Nonce;
	}
}

This API is implemented at the runtime level, always inside sp_api::impl_runtime_apis!.

As noted, this is already enough to make this API usable via state_call.

§Create a new custom RPC (Legacy)

Should you wish to implement the legacy approach of exposing this runtime-api as a custom RPC-api, then a custom RPC server has to be defined.

#[rpc(client, server)]
pub trait SystemApi<BlockHash, AccountId, Nonce> {
	/// Returns the next valid index (aka nonce) for given account.
	///
	/// This method takes into consideration all pending transactions
	/// currently in the pool and if no transactions are found in the pool
	/// it fallbacks to query the index from the runtime (aka. state nonce).
	#[method(name = "system_accountNextIndex", aliases = ["account_nextIndex"])]
	async fn nonce(&self, account: AccountId) -> RpcResult<Nonce>;

	/// Dry run an extrinsic at a given block. Return SCALE encoded ApplyExtrinsicResult.
	#[method(name = "system_dryRun", aliases = ["system_dryRunAt"], with_extensions)]
	async fn dry_run(&self, extrinsic: Bytes, at: Option<BlockHash>) -> RpcResult<Bytes>;
}

§Add a new RPC to the node (Legacy)

Finally, this custom RPC needs to be integrated into the node side. This is usually done in a rpc.rs in a typical template, as follows:

pub fn create_full<C, P>(
	deps: FullDeps<C, P>,
) -> Result<RpcModule<()>, Box<dyn std::error::Error + Send + Sync>>
where
	C: Send
		+ Sync
		+ 'static
		+ sp_api::ProvideRuntimeApi<OpaqueBlock>
		+ HeaderBackend<OpaqueBlock>
		+ HeaderMetadata<OpaqueBlock, Error = BlockChainError>
		+ 'static,
	C::Api: sp_block_builder::BlockBuilder<OpaqueBlock>,
	C::Api: substrate_frame_rpc_system::AccountNonceApi<OpaqueBlock, AccountId, Nonce>,
	P: TransactionPool + 'static,
{
	use polkadot_sdk::substrate_frame_rpc_system::{System, SystemApiServer};
	let mut module = RpcModule::new(());
	let FullDeps { client, pool } = deps;

	module.merge(System::new(client.clone(), pool.clone()).into_rpc())?;

	Ok(module)
}

§Future