mit einem Klick
implement-rpc-api
// How to implement a new JSON-RPC API in this codebase — defining the API trait, types, error enum, and server handler.
// How to implement a new JSON-RPC API in this codebase — defining the API trait, types, error enum, and server handler.
| name | implement-rpc-api |
| description | How to implement a new JSON-RPC API in this codebase — defining the API trait, types, error enum, and server handler. |
This skill describes how to add a new JSON-RPC API namespace to Katana. There are five steps:
katana-rpc-api)katana-rpc-types)katana-rpc-api)katana-rpc-server)katana-sequencer-node/katana-full-node for sequencer/full node respectively)The crates involved:
| Crate | Path | Purpose |
|---|---|---|
katana-rpc-api | crates/rpc/rpc-api/ | API trait definitions and error types |
katana-rpc-types | crates/rpc/rpc-types/ | RPC request/response types |
katana-rpc-server | crates/rpc/rpc-server/ | Server-side implementations |
katana-sequencer-node | crates/node/sequencer/ | Sequencer node — wires RPC modules into the server |
katana-full-node | crates/node/full/ | Full node — wires RPC modules into the server |
katana-node-config | crates/node/config/ | Node configuration including RpcModuleKind |
Throughout this guide, <name> is the API namespace (e.g., dev, tee, starknet).
Create a new module in crates/rpc/rpc-api/src/<name>.rs and define the trait using the jsonrpsee proc macro.
<Name>Api — PascalCase of the namespace with Api suffix (e.g., DevApi, TeeApi).namespace attribute in the #[rpc(...)] macro must match the JSON-RPC namespace exactly (e.g., "dev" produces methods like dev_generateBlock).#[method(name = "...")] attribute with camelCase (e.g., "generateBlock"). The Rust function name uses snake_case.use jsonrpsee::core::RpcResult;
use jsonrpsee::proc_macros::rpc;
#[cfg_attr(not(feature = "client"), rpc(server, namespace = "<name>"))]
#[cfg_attr(feature = "client", rpc(client, server, namespace = "<name>"))]
pub trait <Name>Api {
/// Brief description of what this method does.
#[method(name = "methodName")]
async fn method_name(&self, param: ParamType) -> RpcResult<ResponseType>;
}
async.RpcResult<T> (alias for Result<T, jsonrpsee::types::ErrorObjectOwned>).#[cfg_attr] pattern enables client code generation only when the client feature is active, keeping the server build lighter.StarknetApi::spec_version for an example.Add the new module to crates/rpc/rpc-api/src/lib.rs:
pub mod <name>;
If the API is feature-gated:
#[cfg(feature = "<feature>")]
pub mod <name>;
crates/rpc/rpc-api/src/dev.rs — DevApi with straightforward methods.crates/rpc/rpc-api/src/tee.rs — TeeApi behind the tee feature.crates/rpc/rpc-api/src/starknet.rs — Multiple traits sharing the same namespace.If the API uses request/response types that don't already exist in katana-primitives or katana-rpc-types, define them in crates/rpc/rpc-types/src/.
Debug, Clone, Serialize, Deserialize.#[serde(rename_all = "camelCase")] for field names that should be camelCase in JSON.#[serde(tag = "type")] for enum variants that should be discriminated by a type field.#[serde(flatten)] to inline nested structs.serde_utils (e.g., serialize_as_hex, deserialize_u128).katana-primitives) should implement From conversions.use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct <Name>Response {
pub field_one: String,
pub field_two: u64,
}
Every API namespace must have its own error enum in crates/rpc/rpc-api/src/error/. Create crates/rpc/rpc-api/src/error/<name>.rs.
use jsonrpsee::types::ErrorObjectOwned;
#[derive(thiserror::Error, Clone, Debug)]
pub enum <Name>ApiError {
#[error("Description of error A")]
ErrorA,
#[error("Description of error B: {0}")]
ErrorB(String),
}
impl From<<Name>ApiError> for ErrorObjectOwned {
fn from(err: <Name>ApiError) -> Self {
let code = match &err {
<Name>ApiError::ErrorA => 1,
<Name>ApiError::ErrorB(_) => 2,
};
ErrorObjectOwned::owned(code, err.to_string(), None::<()>)
}
}
thiserror::Error for the enum.i32 error code.From<...> for ErrorObjectOwned so errors convert to JSON-RPC errors automatically.Some(data) instead of None::<()> in ErrorObjectOwned::owned(...). Define the data struct with Serialize + Deserialize.crates/rpc/rpc-api/src/error/ for codes already in use.Add to crates/rpc/rpc-api/src/error/mod.rs:
pub mod <name>;
error/katana.rs — integer error codes via #[repr(i32)] enum discriminants.error/dev.rs — UnexpectedErrorData passed as error data.error/tee.rs — error codes starting at 100 to avoid conflicts.Create a new module in crates/rpc/rpc-server/src/<name>.rs (or crates/rpc/rpc-server/src/<name>/mod.rs if the implementation is large enough to split into submodules).
Result<T, <Name>ApiError>.<Name>ApiServer trait (generated by the proc macro), delegating to internal methods.use std::sync::Arc;
use jsonrpsee::core::{async_trait, RpcResult};
use katana_rpc_api::<name>::<Name>ApiServer;
use katana_rpc_api::error::<name>::<Name>ApiError;
#[allow(missing_debug_implementations)]
pub struct <Name>Api {
// Dependencies: storage providers, backend, etc.
// Wrap shared state in Arc for cheap cloning.
}
impl <Name>Api {
pub fn new(/* deps */) -> Self {
Self { /* ... */ }
}
// Internal methods with concrete error types.
fn some_internal_method(&self) -> Result<(), <Name>ApiError> {
// ...
Ok(())
}
}
#[async_trait]
impl <Name>ApiServer for <Name>Api {
async fn method_name(&self, param: ParamType) -> RpcResult<ResponseType> {
// Delegate to internal method; the ? operator converts
// <Name>ApiError -> ErrorObjectOwned automatically.
Ok(self.some_internal_method()?)
}
}
ProviderFactory (see DevApi<PF> and TeeApi<PF>).Arc if the handler needs to be cloned (required when registering with jsonrpsee).on_io_blocking_task or on_cpu_blocking_task patterns (see StarknetApi). For simpler APIs this isn't needed.? operator chains From<ApiError> for ErrorObjectOwned so that trait methods can use Ok(self.internal_method()?).Add to crates/rpc/rpc-server/src/lib.rs:
pub mod <name>;
If the API is feature-gated:
#[cfg(feature = "<feature>")]
pub mod <name>;
crates/rpc/rpc-server/src/dev.rs — DevApi with direct method calls.crates/rpc/rpc-server/src/tee.rs — TeeApi<PF> parameterized by provider factory.crates/rpc/rpc-server/src/starknet/ — split into read.rs, write.rs, trace.rs.The final step is wiring the new API into the node so it actually gets served. Registration happens in Node::build_with_provider in crates/node/sequencer/src/lib.rs. There are also other node implementations (e.g., crates/node/full/src/lib.rs) that may need the same registration if applicable.
RpcModuleKindIf the API should be toggleable at runtime (most APIs should be), add a variant to the RpcModuleKind enum in crates/node/config/src/rpc.rs:
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RpcModuleKind {
Starknet,
Dev,
Katana,
// Add your new variant:
<Name>,
}
For feature-gated APIs, annotate the variant:
#[cfg(feature = "<feature>")]
<Name>,
Node::build_with_providerIn crates/node/sequencer/src/lib.rs, add the imports and registration block. The registration goes in the build_with_provider method, in the section where other RPC modules are merged into rpc_modules (around lines 309–358).
Add imports at the top of the file:
use katana_rpc_api::<name>::<Name>ApiServer;
use katana_rpc_server::<name>::<Name>Api;
For feature-gated APIs, wrap the imports:
#[cfg(feature = "<feature>")]
use katana_rpc_api::<name>::<Name>ApiServer;
#[cfg(feature = "<feature>")]
use katana_rpc_server::<name>::<Name>Api;
Add the registration block alongside the existing API registrations:
// --- Always-on API (like Dev)
if config.rpc.apis.contains(&RpcModuleKind::<Name>) {
let api = <Name>Api::new(/* deps from the build context: backend, pool, provider, etc. */);
rpc_modules.merge(<Name>ApiServer::into_rpc(api))?;
}
For feature-gated APIs (like TEE):
#[cfg(feature = "<feature>")]
if config.rpc.apis.contains(&RpcModuleKind::<Name>) {
let api = <Name>Api::new(/* deps */);
rpc_modules.merge(<Name>ApiServer::into_rpc(api))?;
}
The registration block goes after the existing API registrations and before the RpcServer::new() builder call. Follow the existing ordering in build_with_provider:
RpcServer::new().module(rpc_modules)? — builds the serverInside build_with_provider, these objects are available to pass to your handler constructor:
| Variable | Type | Description |
|---|---|---|
backend | Arc<Backend<P>> | Node backend (chain spec, executor, storage, gas oracle) |
block_producer | BlockProducer<P> | Block production control |
pool | TxPool | Transaction mempool |
provider | P (impl ProviderFactory) | Storage provider factory |
task_spawner | TaskSpawner | Async task spawner for blocking work |
gas_oracle | GasPriceOracle | Gas price oracle |
config | Config | Full node configuration |
If the API should also be available in the full node (not just the sequencer), apply the same registration in crates/node/full/src/lib.rs. The pattern is identical.
Cargo.toml dependenciesAdd the katana-rpc-api and katana-rpc-server crates as dependencies of the node crate (crates/node/sequencer/Cargo.toml) if they aren't already listed. For feature-gated APIs, gate the dependencies under the appropriate feature.
Look at how existing APIs are registered in crates/node/sequencer/src/lib.rs:
RpcModuleKind::Dev.#[cfg(feature = "tee")] and gated on config.tee being set (enabled via the --tee <PROVIDER> CLI flag rather than an RpcModuleKind variant), with provider initialization logic.crates/rpc/rpc-api/src/<name>.rscrates/rpc/rpc-api/src/lib.rscrates/rpc/rpc-api/src/error/<name>.rscrates/rpc/rpc-api/src/error/mod.rscrates/rpc/rpc-types/src/ (if needed)crates/rpc/rpc-server/src/<name>.rscrates/rpc/rpc-server/src/lib.rsRpcModuleKind variant added in crates/node/config/src/rpc.rsNode::build_with_provider (crates/node/sequencer/src/lib.rs)crates/node/full/src/lib.rs)Cargo.toml files