diff --git a/README.md b/README.md index b9be04a..5380942 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,8 @@ curl -sS http://localhost:8899 \ Fumarole ingest includes a default memory soft-limit backpressure guard; set `fumarole-memory-soft-limit-bytes: 0` only if you want to disable it. - `superbank-rpc` is configured via CLI flags and environment variables. + It can also read RPC parameter filters from the shared YAML file when started + with `--config superbank.yaml` / `SUPERBANK_CONFIG=superbank.yaml`. See `crates/superbank-rpc/README.md`. ## Docker local development diff --git a/crates/superbank-rpc/README.md b/crates/superbank-rpc/README.md index 9e9c559..5cac711 100644 --- a/crates/superbank-rpc/README.md +++ b/crates/superbank-rpc/README.md @@ -102,6 +102,29 @@ CLICKHOUSE_URL=http://localhost:8123 CLICKHOUSE_DATABASE=default \ cargo run -p superbank-rpc -- ``` +## Exact method and parameter filters + +`superbank-rpc` can reject configured method and parameter combinations before they enter handler +dispatch or use any cache or ClickHouse resources. Pass the shared YAML configuration with +`--config superbank.yaml` or `SUPERBANK_CONFIG=superbank.yaml` and add: + +```yaml +rpc-parameter-filters: + - [getTransactionsForAddress, So11111111111111111111111111111111111111112] + - [getTransactionsForAddress, So11111111111111111111111111111111111111112, {transactionDetails: signatures}] +``` + +Each entry contains the case-sensitive method followed by its complete parameter array. Matching +is structural and exact: parameter count, array order, JSON types, and values must match; mapping +key order does not matter. Extra parameters do not match. A method-only entry matches an explicitly +empty `params: []` array; omitted `params` is distinct. + +A matching call returns HTTP `405 Method Not Allowed` and preserves the request ID in a JSON-RPC +error with code `-32601` and message `Method not allowed`. In a mixed batch, allowed calls still +execute and matched calls receive individual errors in their original positions; the batch HTTP +status is 405 if any item matched. Filters are validated and indexed once at startup, so changing +the file requires restarting `superbank-rpc`. + ## Optional Superbank gRPC streaming (`grpc-streaming`) When compiled with `--features grpc-streaming` and enabled at runtime, superbank-rpc serves a diff --git a/crates/superbank-rpc/src/config.rs b/crates/superbank-rpc/src/config.rs index 9b66ba0..ed7fede 100644 --- a/crates/superbank-rpc/src/config.rs +++ b/crates/superbank-rpc/src/config.rs @@ -3,7 +3,7 @@ * Copyright 2025-2026 Triton One Limited. All rights reserved. */ -use std::str::FromStr; +use std::{path::PathBuf, str::FromStr}; use clap::{ArgAction, Parser}; use solana_sdk::pubkey::Pubkey; @@ -54,6 +54,10 @@ pub enum PyroscopeCompression { about = "Solana RPC server serving data from ClickHouse" )] pub struct RpcConfig { + /// Path to the shared YAML configuration file. + #[arg(long, env = "SUPERBANK_CONFIG", value_name = "PATH")] + pub(crate) config: Option, + /// Maximum accepted JSON-RPC request body size (bytes). #[arg(long, env = "RPC_MAX_BODY_BYTES", default_value_t = 1_048_576)] pub(crate) rpc_max_body_bytes: usize, @@ -742,6 +746,17 @@ mod config_tests { assert_eq!(cfg.get_inflation_reward_max_bytes_to_read, 536_870_912); } + #[test] + fn shared_config_path_flag_parses() { + let _guard = ENV_LOCK.lock().expect("env lock"); + let cfg = RpcConfig::parse_from(["superbank-rpc", "--config", "superbank.yaml"]); + + assert_eq!( + cfg.config.as_deref(), + Some(std::path::Path::new("superbank.yaml")) + ); + } + #[test] fn inflation_reward_limits_parse() { let cfg = RpcConfig::parse_from([ diff --git a/crates/superbank-rpc/src/handlers/mod.rs b/crates/superbank-rpc/src/handlers/mod.rs index d6989e6..f5161bd 100644 --- a/crates/superbank-rpc/src/handlers/mod.rs +++ b/crates/superbank-rpc/src/handlers/mod.rs @@ -56,6 +56,8 @@ const ROUTE_HEADER_LABEL_MISSING: &str = "missing"; const JSON_RPC_INTERNAL_ERROR_CODE: i64 = -32603; const JSON_RPC_REQUEST_TIMEOUT_CODE: i64 = -32000; const JSON_RPC_REQUEST_TIMEOUT_MESSAGE: &str = "Request timeout"; +const JSON_RPC_METHOD_NOT_ALLOWED_CODE: i32 = -32601; +const JSON_RPC_METHOD_NOT_ALLOWED_MESSAGE: &str = "Method not allowed"; const HEADER_X_ENDPOINT: &str = "X-Endpoint"; const HEADER_X_RPC_NODE: &str = "X-RPC-Node"; const HEADER_X_SUBSCRIPTION_ID: &str = "X-Subscription-ID"; @@ -639,6 +641,32 @@ fn json_rpc_error_value( Value::Object(response) } +fn observe_parameter_filter_match(method: &str) { + if let Some(tracker) = metrics::track_request(metrics_method_label(method)) { + tracker.observe(StatusCode::METHOD_NOT_ALLOWED); + } +} + +fn parameter_filter_response(id: Value) -> Response { + let mut response = json_rpc_error_response( + id, + JSON_RPC_METHOD_NOT_ALLOWED_CODE, + JSON_RPC_METHOD_NOT_ALLOWED_MESSAGE, + None, + ); + *response.status_mut() = StatusCode::METHOD_NOT_ALLOWED; + response +} + +fn parameter_filter_response_value(id: Value) -> Value { + json_rpc_error_value( + id, + JSON_RPC_METHOD_NOT_ALLOWED_CODE, + JSON_RPC_METHOD_NOT_ALLOWED_MESSAGE, + None, + ) +} + fn is_http_503_eligible_json_rpc_error(code: i64, message: Option<&str>) -> bool { if code == JSON_RPC_INTERNAL_ERROR_CODE { return true; @@ -1059,6 +1087,19 @@ async fn handle_single_request( } }; + if state + .rpc_parameter_filters + .matches(&request.method, request.params.as_deref()) + { + let method = request.method; + let id = request.id.unwrap_or(Value::Null); + return metrics::with_request_metric_labels(request_metric_labels, async move { + observe_parameter_filter_match(&method); + Ok(parameter_filter_response(id)) + }) + .await; + } + let timeout = state.rpc_request_timeout; let dispatch_request = request.into_dispatch_request(); let response = metrics::with_request_metric_labels( @@ -1084,10 +1125,23 @@ async fn execute_batch_requests( let mut responses: Vec> = (0..batch_len).map(|_| None).collect(); let mut join_set: JoinSet = JoinSet::new(); + let mut parameter_filter_matched = false; for (idx, request_value) in requests.into_iter().enumerate() { match parse_json_rpc_request(request_value) { Ok(request) => { + if state + .rpc_parameter_filters + .matches(&request.method, request.params.as_deref()) + { + observe_parameter_filter_match(&request.method); + responses[idx] = Some(parameter_filter_response_value( + request.id.unwrap_or(Value::Null), + )); + parameter_filter_matched = true; + continue; + } + let response_id = request.id.clone().unwrap_or(Value::Null); let dispatch_request = request.into_dispatch_request(); let state = state.clone(); @@ -1159,7 +1213,11 @@ async fn execute_batch_requests( response_values.push(value); } - Ok(Json(Value::Array(response_values)).into_response()) + let mut response = Json(Value::Array(response_values)).into_response(); + if parameter_filter_matched { + *response.status_mut() = StatusCode::METHOD_NOT_ALLOWED; + } + Ok(response) } async fn handle_batch_request( diff --git a/crates/superbank-rpc/src/lib.rs b/crates/superbank-rpc/src/lib.rs index 5618c3f..94869e4 100644 --- a/crates/superbank-rpc/src/lib.rs +++ b/crates/superbank-rpc/src/lib.rs @@ -8,6 +8,7 @@ mod clickhouse; mod metrics; mod processing; +mod request_filter; mod config; #[cfg(feature = "disk-cache")] diff --git a/crates/superbank-rpc/src/request_filter.rs b/crates/superbank-rpc/src/request_filter.rs new file mode 100644 index 0000000..5cc95f7 --- /dev/null +++ b/crates/superbank-rpc/src/request_filter.rs @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: AGPL-3.0-only +/* + * Copyright 2025-2026 Triton One Limited. All rights reserved. + */ + +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Default, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +struct RpcFileConfig { + rpc_parameter_filters: Vec>, +} + +/// Startup-compiled RPC request filters. +/// +/// The first lookup excludes unfiltered methods. The arity lookup then excludes +/// differently-shaped calls before any structural JSON comparisons are needed. +#[derive(Debug, Default)] +pub(crate) struct RpcParameterFilterSet { + by_method_and_arity: HashMap>>>, + len: usize, +} + +impl RpcParameterFilterSet { + pub(crate) fn load(path: Option<&Path>) -> Result { + let Some(path) = path else { + return Ok(Self::default()); + }; + + let contents = fs::read_to_string(path) + .map_err(|err| format!("failed to read config file '{}': {err}", path.display()))?; + let config: RpcFileConfig = serde_yaml::from_str(&contents) + .map_err(|err| format!("failed to parse config file '{}': {err}", path.display()))?; + Self::from_entries(config.rpc_parameter_filters, Some(path)) + } + + pub(crate) fn matches(&self, method: &str, params: Option<&[Value]>) -> bool { + if self.len == 0 { + return false; + } + let Some(params) = params else { + return false; + }; + self.by_method_and_arity + .get(method) + .and_then(|by_arity| by_arity.get(¶ms.len())) + .is_some_and(|candidates| candidates.iter().any(|candidate| candidate == params)) + } + + pub(crate) fn len(&self) -> usize { + self.len + } + + pub(crate) fn from_entries( + entries: Vec>, + path: Option<&Path>, + ) -> Result { + let mut filters = Self::default(); + for (index, mut entry) in entries.into_iter().enumerate() { + if entry.is_empty() { + return Err(Self::entry_error(path, index, "entry must not be empty")); + } + let method = entry.remove(0); + let Value::String(method) = method else { + return Err(Self::entry_error( + path, + index, + "first value must be a method string", + )); + }; + if method.trim().is_empty() { + return Err(Self::entry_error(path, index, "method must not be empty")); + } + + let candidates = filters + .by_method_and_arity + .entry(method) + .or_default() + .entry(entry.len()) + .or_default(); + if !candidates.contains(&entry) { + candidates.push(entry); + filters.len += 1; + } + } + Ok(filters) + } + + fn entry_error(path: Option<&Path>, index: usize, message: &str) -> String { + let source = path + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from("")); + format!( + "invalid rpc-parameter-filters entry {} in '{}': {message}", + index + 1, + source.display() + ) + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::RpcParameterFilterSet; + + fn filters(entries: Vec>) -> RpcParameterFilterSet { + RpcParameterFilterSet::from_entries(entries, None).expect("valid filters") + } + + use serde_json::Value; + + #[test] + fn exact_match_is_indexed_by_method_and_arity() { + let filters = filters(vec![ + vec![json!("getThing"), json!("address")], + vec![json!("getThing"), json!("address"), json!({"mode": "full"})], + ]); + + assert!(filters.matches("getThing", Some(&[json!("address")]))); + assert!(filters.matches( + "getThing", + Some(&[json!("address"), json!({"mode": "full"})]) + )); + assert!(!filters.matches("getthing", Some(&[json!("address")]))); + assert!(!filters.matches( + "getThing", + Some(&[json!("address"), json!({"mode": "other"})]) + )); + assert!(!filters.matches( + "getThing", + Some(&[json!("address"), json!({"mode": "full"}), json!(true)]) + )); + assert!(!filters.matches("getThing", None)); + } + + #[test] + fn object_key_order_does_not_affect_equality() { + let filters = filters(vec![vec![ + json!("getThing"), + json!({"first": 1, "second": 2}), + ]]); + + assert!(filters.matches("getThing", Some(&[json!({"second": 2, "first": 1})]))); + } + + #[test] + fn duplicate_entries_are_removed() { + let filters = filters(vec![ + vec![json!("getThing"), json!("address")], + vec![json!("getThing"), json!("address")], + ]); + + assert_eq!(filters.len(), 1); + } + + #[test] + fn invalid_entries_are_rejected_with_index() { + let err = RpcParameterFilterSet::from_entries( + vec![vec![json!("getThing"), json!(1)], vec![json!(42)]], + None, + ) + .expect_err("non-string method must fail"); + + assert!(err.contains("entry 2")); + assert!(err.contains("first value must be a method string")); + } + + #[test] + fn shared_yaml_ignores_ingestor_keys_and_loads_filters() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("superbank.yaml"); + std::fs::write( + &path, + r#" +source: grpc +endpoint: https://example.invalid +rpc-parameter-filters: + - [getThing, address] +"#, + ) + .expect("write config"); + + let filters = RpcParameterFilterSet::load(Some(&path)).expect("load config"); + assert!(filters.matches("getThing", Some(&[json!("address")]))); + } +} diff --git a/crates/superbank-rpc/src/server.rs b/crates/superbank-rpc/src/server.rs index 209ff71..7aa29a6 100644 --- a/crates/superbank-rpc/src/server.rs +++ b/crates/superbank-rpc/src/server.rs @@ -27,6 +27,7 @@ use crate::handlers::handle_json_rpc_with_headers; use crate::metrics; use crate::metrics::metrics_handler; use crate::processing::ProcessingError; +use crate::request_filter::RpcParameterFilterSet; use crate::state::{AppState, LatestBlockHeightCache, LatestSlotCache, MetricsHeaderCaptureConfig}; #[cfg(feature = "grpc-streaming")] @@ -109,6 +110,12 @@ pub enum RpcError { pub async fn run_server(args: RpcConfig) -> RpcResult<()> { info!("Starting Solana RPC server on {}:{}", args.host, args.port); + let rpc_parameter_filters = + RpcParameterFilterSet::load(args.config.as_deref()).map_err(RpcError::Config)?; + info!( + filters = rpc_parameter_filters.len(), + "RPC parameter filters loaded" + ); info!( transport = ?args.clickhouse_transport, scope = ?args.clickhouse_scope, @@ -319,6 +326,7 @@ pub async fn run_server(args: RpcConfig) -> RpcResult<()> { let state = Arc::new(AppState { clickhouse, + rpc_parameter_filters, max_signatures_limit: args.max_signatures_limit, rpc_max_batch_size: args.rpc_max_batch_size.max(1), rpc_batch_concurrency_limit: args.rpc_batch_concurrency_limit.max(1), diff --git a/crates/superbank-rpc/src/state.rs b/crates/superbank-rpc/src/state.rs index f975a98..d60d2e0 100644 --- a/crates/superbank-rpc/src/state.rs +++ b/crates/superbank-rpc/src/state.rs @@ -14,6 +14,7 @@ use tokio::sync::{Mutex, Notify, Semaphore}; use crate::clickhouse::ClickHouseClient; use crate::metrics; use crate::processing::ProcessingError; +use crate::request_filter::RpcParameterFilterSet; use crate::util::{current_time_millis, ttl_millis}; #[cfg(feature = "disk-cache")] @@ -31,6 +32,7 @@ pub(crate) struct MetricsHeaderCaptureConfig { pub(crate) struct AppState { pub(crate) clickhouse: ClickHouseClient, + pub(crate) rpc_parameter_filters: RpcParameterFilterSet, pub(crate) max_signatures_limit: u64, pub(crate) rpc_max_batch_size: usize, pub(crate) rpc_batch_concurrency_limit: usize, diff --git a/crates/superbank-rpc/src/tests/mod.rs b/crates/superbank-rpc/src/tests/mod.rs index a614921..ae186c0 100644 --- a/crates/superbank-rpc/src/tests/mod.rs +++ b/crates/superbank-rpc/src/tests/mod.rs @@ -56,6 +56,7 @@ use crate::hydration::{ parse_transaction_error_display, }; use crate::metrics; +use crate::request_filter::RpcParameterFilterSet; use crate::rpc::json_rpc_error_response; use crate::rpc::types::{JsonRpcRequest, JsonRpcResponse as JsonRpcResponseGeneric}; use crate::state::{AppState, LatestBlockHeightCache, LatestSlotCache, MetricsHeaderCaptureConfig}; @@ -154,6 +155,7 @@ fn test_state_with_token_owner_activity_available(available: bool) -> Arc Arc { test_state_with_token_owner_activity_available(true) } +fn test_state_with_parameter_filters(entries: Vec>) -> Arc { + let mut state = match Arc::try_unwrap(test_state()) { + Ok(state) => state, + Err(_) => panic!("test_state should have a single Arc owner"), + }; + state.rpc_parameter_filters = + RpcParameterFilterSet::from_entries(entries, None).expect("valid test filters"); + Arc::new(state) +} + fn test_state_with_metrics_header_capture(capture: MetricsHeaderCaptureConfig) -> Arc { let mut state = match Arc::try_unwrap(test_state()) { Ok(state) => state, @@ -235,6 +247,7 @@ fn test_state_with_clickhouse_url(clickhouse_url: &str) -> Arc { Arc::new(AppState { clickhouse, + rpc_parameter_filters: Default::default(), max_signatures_limit: TEST_MAX_LIMIT, rpc_max_batch_size: 64, rpc_batch_concurrency_limit: 8, @@ -292,6 +305,7 @@ async fn test_state_with_clickhouse_cached_signature_slot( Arc::new(AppState { clickhouse, + rpc_parameter_filters: Default::default(), max_signatures_limit: TEST_MAX_LIMIT, rpc_max_batch_size: 64, rpc_batch_concurrency_limit: 8, @@ -341,6 +355,7 @@ fn test_state_with_head_cache(head_cache: Arc) -> Arc { Arc::new(AppState { clickhouse, + rpc_parameter_filters: Default::default(), max_signatures_limit: TEST_MAX_LIMIT, rpc_max_batch_size: 64, rpc_batch_concurrency_limit: 8, @@ -392,6 +407,7 @@ fn test_state_with_head_cache_and_clickhouse_url( Arc::new(AppState { clickhouse, + rpc_parameter_filters: Default::default(), max_signatures_limit: TEST_MAX_LIMIT, rpc_max_batch_size: 64, rpc_batch_concurrency_limit: 8, @@ -450,6 +466,7 @@ async fn test_state_with_head_cache_and_cached_signature_slot( Arc::new(AppState { clickhouse, + rpc_parameter_filters: Default::default(), max_signatures_limit: TEST_MAX_LIMIT, rpc_max_batch_size: 64, rpc_batch_concurrency_limit: 8, @@ -3506,6 +3523,78 @@ async fn handle_json_rpc_method_not_found_returns_json_rpc_error() { assert!(parsed.result.is_none()); } +#[tokio::test] +async fn parameter_filter_returns_http_405_before_handler_dispatch() { + let address = "So11111111111111111111111111111111111111112"; + let state = test_state_with_parameter_filters(vec![vec![ + json!("getTransactionsForAddress"), + json!(address), + json!({"transactionDetails": "signatures"}), + ]]); + let request = json!({ + "jsonrpc": "2.0", + "id": 42, + "method": "getTransactionsForAddress", + "params": [address, {"transactionDetails": "signatures"}] + }); + + let response = handle_json_rpc_value(state, &request).await; + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + let parsed = parse_json_rpc_response(response).await; + assert_eq!(parsed.id, json!(42)); + let err = parsed.error.expect("error present"); + assert_eq!(err.code, -32601); + assert_eq!(err.message, "Method not allowed"); + assert!(err.data.is_none()); +} + +#[tokio::test] +async fn parameter_filter_requires_complete_param_equality() { + let address = "So11111111111111111111111111111111111111112"; + let state = test_state_with_parameter_filters(vec![vec![ + json!("getTransactionsForAddress"), + json!(address), + ]]); + let request = json!({ + "jsonrpc": "2.0", + "id": 42, + "method": "getTransactionsForAddress", + "params": [address, {"transactionDetails": "signatures"}] + }); + + let response = handle_json_rpc_value(state, &request).await; + + assert_eq!(response.status(), StatusCode::OK); + let parsed = parse_json_rpc_response(response).await; + assert_ne!( + parsed + .error + .expect("handler error proves dispatch occurred") + .message, + "Method not allowed" + ); +} + +#[tokio::test] +async fn parameter_filter_does_not_override_invalid_request_errors() { + let state = test_state_with_parameter_filters(vec![vec![json!("unknownMethod"), json!(1)]]); + let request = json!({ + "jsonrpc": "1.0", + "id": 7, + "method": "unknownMethod", + "params": [1] + }); + + let response = handle_json_rpc_value(state, &request).await; + + assert_eq!(response.status(), StatusCode::OK); + let parsed = parse_json_rpc_response(response).await; + let err = parsed.error.expect("error present"); + assert_eq!(err.code, -32600); + assert_eq!(err.message, "Invalid JSON-RPC version"); +} + #[tokio::test] async fn handle_json_rpc_minimum_ledger_slot_routes_to_handler() { let state = test_state_with_clickhouse_url("http://127.0.0.1:1"); @@ -3645,6 +3734,40 @@ async fn handle_json_rpc_batch_preserves_input_order() { assert_eq!(results[1].get("id"), Some(&json!(2))); } +#[tokio::test] +async fn parameter_filter_marks_mixed_batch_405_and_executes_allowed_items() { + let state = + test_state_with_parameter_filters(vec![vec![json!("blockedMethod"), json!("blocked")]]); + let request = json!([ + { + "jsonrpc": "2.0", + "id": 1, + "method": "blockedMethod", + "params": ["blocked"] + }, + { + "jsonrpc": "2.0", + "id": 2, + "method": "unknownMethod", + "params": [] + } + ]); + + let response = handle_json_rpc_value(state, &request).await; + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + let parsed = parse_json_value_response(response).await; + let results = parsed.as_array().expect("batch response array"); + assert_eq!(results.len(), 2); + assert_eq!(results[0]["id"], json!(1)); + assert_eq!(results[0]["error"]["message"], json!("Method not allowed")); + assert_eq!(results[1]["id"], json!(2)); + assert_eq!( + results[1]["error"]["message"], + json!("Method not found: unknownMethod") + ); +} + #[tokio::test] async fn handle_json_rpc_batch_includes_missing_id_item_with_null_id() { let state = test_state(); diff --git a/crates/superbank/src/cli.rs b/crates/superbank/src/cli.rs index edbd81a..67e22b9 100644 --- a/crates/superbank/src/cli.rs +++ b/crates/superbank/src/cli.rs @@ -633,6 +633,8 @@ pub(crate) struct Args { #[derive(Debug, Default, Deserialize)] #[serde(default, rename_all = "kebab-case", deny_unknown_fields)] struct FileConfig { + #[serde(rename = "rpc-parameter-filters")] + _rpc_parameter_filters: Option>>, source: Option, endpoint: Option, #[serde(alias = "x_token")] @@ -1687,6 +1689,25 @@ mod tests { assert!(err.to_string().contains("fumarole-creat-consumer-group")); } + #[test] + fn file_config_accepts_rpc_parameter_filters() { + let config = serde_yaml::from_str::( + r#" +rpc-parameter-filters: + - [getTransactionsForAddress, So11111111111111111111111111111111111111112] +"#, + ) + .expect("parse shared config"); + + assert_eq!( + config + ._rpc_parameter_filters + .as_ref() + .map(|filters| filters.len()), + Some(1) + ); + } + #[test] fn grpc_health_watch_enabled_defaults_to_true() { let matches = CliArgs::command().get_matches_from(["superbank", "--source", "grpc"]); diff --git a/superbank.example.yaml b/superbank.example.yaml index d2032ee..1cfb472 100644 --- a/superbank.example.yaml +++ b/superbank.example.yaml @@ -1,5 +1,13 @@ # Superbank ingestor example configuration. # Copy to superbank.yaml and pass via --config / SUPERBANK_CONFIG. +# +# Optional superbank-rpc exact request filters. Pass this same file to +# superbank-rpc with --config / SUPERBANK_CONFIG. Each entry contains the +# method followed by its complete params array. Matching calls return HTTP 405 +# with a JSON-RPC "Method not allowed" error and do not reach RPC handlers. +# rpc-parameter-filters: +# - [getTransactionsForAddress, So11111111111111111111111111111111111111112] +# - [getTransactionsForAddress, So11111111111111111111111111111111111111112, {transactionDetails: signatures}] source: "grpc" # fumarole | grpc | rpc | bigtable | solparq