Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
958d873
Work in progress: initial implementation
amir-deris Aug 11, 2026
1d9d63e
Turned off Comet BFT rate limiter by default
amir-deris Aug 13, 2026
a59e2ec
Fixed empty catalogue issue
amir-deris Aug 13, 2026
11b2a8c
Harden CometBFT RPC rate limiter config and admission edge cases.
amir-deris Aug 13, 2026
2e8fcc7
Return JSON-RPC errors for POST rate-limit rejections and expand tests.
amir-deris Aug 13, 2026
5dd7838
Merge branch 'main' into amir/plt-981-rate-limiter-comet-bft
amir-deris Aug 13, 2026
47e4f2e
Refactored to use RequestBatchSizeLimit instead of magic number
amir-deris Aug 14, 2026
24e706f
Added error handling for rate limit construction
amir-deris Aug 14, 2026
5368fe0
Shared rate limit gate for all listeners
amir-deris Aug 14, 2026
edbdc18
Fixed CORS handler order
amir-deris Aug 14, 2026
c35b819
Preserve unlimited max-body-bytes when CometBFT rate limiting is enab…
amir-deris Aug 14, 2026
b5beb3d
Dropped double charge branch
amir-deris Aug 14, 2026
4645d42
Fixed http status StatusRequestEntityTooLarge
amir-deris Aug 14, 2026
b4df254
Added rate limit bucket for method catalogue
amir-deris Aug 14, 2026
27ed5a8
Allign middleware classification with mux
amir-deris Aug 14, 2026
1a9d586
Fixed deferred drain issue
amir-deris Aug 14, 2026
ff787ad
Merge branch 'main' into amir/plt-981-rate-limiter-comet-bft
amir-deris Aug 14, 2026
c7948b4
Added log during startup for negative rate limit values
amir-deris Aug 17, 2026
6922f76
Removed Options rate limiter exemption
amir-deris Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions sei-tendermint/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"
"time"

"github.com/sei-protocol/sei-chain/ratelimiter"
mempoolcfg "github.com/sei-protocol/sei-chain/sei-tendermint/internal/mempool"
tmos "github.com/sei-protocol/sei-chain/sei-tendermint/libs/os"
"github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils"
Expand Down Expand Up @@ -535,6 +536,29 @@ type RPCConfig struct {
// concurrent search load: it is shared across requests, not applied per-query.
// 0 disables the cap (not recommended on public nodes).
MaxSearchScanBudget int `mapstructure:"max-search-scan-budget"`

// IPRateLimitRPS is the per-IP sustained request rate in requests/second for
// CometBFT RPC HTTP (:26657). Zero disables the token bucket (no HTTP 429
// rejections). When rate-limiting-enabled is true, the admission middleware
// still runs: bodies are parsed and oversize/malformed requests are rejected
// before dispatch.
IPRateLimitRPS float64 `mapstructure:"ip-rate-limit-rps"`

// IPRateLimitBurst is the maximum per-IP burst size. Zero disables the token
// bucket (same effect as ip-rate-limit-rps = 0) and does not bypass the
// admission middleware when rate-limiting-enabled is true. Should be at least
// the JSON-RPC batch size limit (10) because the rate limiter charges one
// token per batch element.
IPRateLimitBurst int `mapstructure:"ip-rate-limit-burst"`

// RateLimitingEnabled is the master switch for the rate-limit admission
// middleware on the CometBFT RPC HTTP plane. When false, requests bypass
// method extraction and all rejections from that layer (HTTP 400/413/429).
RateLimitingEnabled bool `mapstructure:"rate-limiting-enabled"`

// TrustedProxyCIDRs lists CIDRs whose X-Forwarded-For headers are trusted when
// resolving the client IP for rate limiting. Empty means trust no proxy.
TrustedProxyCIDRs []string `mapstructure:"trusted-proxy-cidrs"`
}

// DefaultRPCConfig returns a default configuration for the RPC server
Expand Down Expand Up @@ -570,6 +594,11 @@ func DefaultRPCConfig() *RPCConfig {

MaxTxSearchResults: 10_000,
MaxSearchScanBudget: 100_000,

IPRateLimitRPS: 200,
IPRateLimitBurst: 400,
RateLimitingEnabled: false,
TrustedProxyCIDRs: nil,
}
}

Expand Down Expand Up @@ -628,9 +657,22 @@ func (cfg *RPCConfig) ValidateBasic() error {
if cfg.MaxSearchScanBudget < 0 {
return errors.New("max-search-scan-budget can't be negative")
}
if cfg.RateLimitingEnabled && cfg.IPRateLimitBurst > 0 && cfg.IPRateLimitBurst < 10 {
Comment thread
amir-deris marked this conversation as resolved.
Outdated
return fmt.Errorf("ip-rate-limit-burst (%d) must be >= 10: the rate limiter charges one token per batch element",
Comment thread
amir-deris marked this conversation as resolved.
Outdated
cfg.IPRateLimitBurst)
}
return nil
}

// RateLimiterConfig builds the ratelimiter.Config used by CometBFT RPC HTTP admission.
func (cfg *RPCConfig) RateLimiterConfig() ratelimiter.Config {
return ratelimiter.Config{
RPS: cfg.IPRateLimitRPS,
Burst: cfg.IPRateLimitBurst,
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
}
}

// IsCorsEnabled returns true if cross-origin resource sharing is enabled.
func (cfg *RPCConfig) IsCorsEnabled() bool {
return len(cfg.CORSAllowedOrigins) != 0
Expand Down
25 changes: 25 additions & 0 deletions sei-tendermint/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,15 @@ func TestRPCConfigValidateBasic(t *testing.T) {
assert.NoError(t, cfg2.ValidateBasic())
cfg2.TimeoutWrite = 0 // 0 disables; constraint does not apply
assert.NoError(t, cfg2.ValidateBasic())

cfg3 := TestRPCConfig()
cfg3.RateLimitingEnabled = true
cfg3.IPRateLimitBurst = 5
assert.Error(t, cfg3.ValidateBasic())
cfg3.IPRateLimitBurst = 10
assert.NoError(t, cfg3.ValidateBasic())
cfg3.IPRateLimitBurst = 0
assert.NoError(t, cfg3.ValidateBasic())
}

func TestMempoolConfigValidateBasic(t *testing.T) {
Expand Down Expand Up @@ -324,3 +333,19 @@ func TestWalFile_BothExist_LegacyWins(t *testing.T) {
assert.Equal(t, expected, cfg.WalFile(),
"legacy should win when both locations exist")
}

func TestRPCRateLimitKeysKebabCase(t *testing.T) {
const body = `
[rpc]
ip-rate-limit-rps = 42.5
ip-rate-limit-burst = 50
rate-limiting-enabled = true
trusted-proxy-cidrs = ["10.0.0.0/8"]
`
conf, err := unmarshalConfigTOML(t, body)
require.NoError(t, err)
require.Equal(t, 42.5, conf.RPC.IPRateLimitRPS)
require.Equal(t, 50, conf.RPC.IPRateLimitBurst)
require.True(t, conf.RPC.RateLimitingEnabled)
require.Equal(t, []string{"10.0.0.0/8"}, conf.RPC.TrustedProxyCIDRs)
}
22 changes: 22 additions & 0 deletions sei-tendermint/config/toml.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,28 @@ max-tx-search-results = {{ .RPC.MaxTxSearchResults }}
# accumulate. Set to 0 to disable the cap (not recommended on public nodes).
max-search-scan-budget = {{ .RPC.MaxSearchScanBudget }}

# ip-rate-limit-rps is the per-IP sustained request rate in requests/second for
# CometBFT RPC HTTP (:26657). Zero disables the token bucket (no HTTP 429
# rejections). When rate-limiting-enabled is true, the admission middleware still
# runs: bodies are parsed and oversize/malformed requests are rejected before dispatch.
ip-rate-limit-rps = {{ .RPC.IPRateLimitRPS }}

# ip-rate-limit-burst is the maximum per-IP burst above the sustained rate.
# Zero disables the token bucket (same effect as ip-rate-limit-rps = 0) and does
# not bypass the admission middleware when rate-limiting-enabled is true. Must be
# at least 10 when both are positive and rate-limiting-enabled is true because the
# rate limiter charges one token per JSON-RPC batch element.
ip-rate-limit-burst = {{ .RPC.IPRateLimitBurst }}

# rate-limiting-enabled is the master switch for the rate-limit admission
# middleware on the CometBFT RPC HTTP plane. When false, requests bypass method
# extraction and all rejections from that layer (HTTP 400/413/429).
rate-limiting-enabled = {{ .RPC.RateLimitingEnabled }}

# trusted-proxy-cidrs lists CIDRs whose X-Forwarded-For headers are trusted when
# resolving the client IP for rate limiting. Empty means trust no proxy.
trusted-proxy-cidrs = [{{ range .RPC.TrustedProxyCIDRs }}{{ printf "%q, " . }}{{end}}]

#######################################################################
### P2P Configuration Options ###
#######################################################################
Expand Down
15 changes: 15 additions & 0 deletions sei-tendermint/internal/inspect/rpc/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/rs/cors"
"github.com/sei-protocol/seilog"

"github.com/sei-protocol/sei-chain/ratelimiter"
"github.com/sei-protocol/sei-chain/sei-tendermint/config"
"github.com/sei-protocol/sei-chain/sei-tendermint/internal/pubsub"
"github.com/sei-protocol/sei-chain/sei-tendermint/internal/rpc/core"
Expand Down Expand Up @@ -74,6 +75,20 @@ func Handler(rpcConfig *config.RPCConfig, routes core.RoutesMap) http.Handler {
if rpcConfig.IsCorsEnabled() {
rootHandler = addCORSHandler(rpcConfig, mux)
}
var rateLimitGate *server.RateLimitGate
if rpcConfig.RateLimitingEnabled {
rateLimitRegistry, err := ratelimiter.New(rpcConfig.RateLimiterConfig())
if err != nil {
logger.Error("RPC rate limiter disabled: invalid configuration", "err", err)
Comment thread
amir-deris marked this conversation as resolved.
Outdated
} else {
rateLimitGate = server.NewRateLimitGate(
rateLimitRegistry,
rpcConfig.MaxBodyBytes,
true,
)
}
}
rootHandler = server.NewRateLimitMiddleware(rootHandler, rateLimitGate)
return rootHandler
}

Expand Down
25 changes: 25 additions & 0 deletions sei-tendermint/internal/inspect/rpc/rpc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package rpc

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"

"github.com/sei-protocol/sei-chain/sei-tendermint/config"
"github.com/sei-protocol/sei-chain/sei-tendermint/internal/rpc/core"
)

func TestHandler_InvalidTrustedProxyCIDRsDoesNotPanic(t *testing.T) {
cfg := config.DefaultRPCConfig()
cfg.RateLimitingEnabled = true
cfg.TrustedProxyCIDRs = []string{"not-a-cidr"}

require.NotPanics(t, func() {
h := Handler(cfg, core.RoutesMap{})
req := httptest.NewRequest(http.MethodGet, "/status", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
})
}
14 changes: 14 additions & 0 deletions sei-tendermint/internal/rpc/core/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/rs/cors"
"github.com/sei-protocol/seilog"

"github.com/sei-protocol/sei-chain/ratelimiter"
"github.com/sei-protocol/sei-chain/sei-tendermint/config"
"github.com/sei-protocol/sei-chain/sei-tendermint/crypto"
"github.com/sei-protocol/sei-chain/sei-tendermint/internal/blocksync"
Expand Down Expand Up @@ -362,6 +363,19 @@ func (env *Environment) StartService(ctx context.Context, conf *config.Config) (
})
rootHandler = corsMiddleware.Handler(mux)
}
var rateLimitGate *rpcserver.RateLimitGate
if conf.RPC.RateLimitingEnabled {
rateLimitRegistry, err := ratelimiter.New(conf.RPC.RateLimiterConfig())
Comment thread
amir-deris marked this conversation as resolved.
Outdated
if err != nil {
return nil, fmt.Errorf("rpc rate limiter: %w", err)
}
rateLimitGate = rpcserver.NewRateLimitGate(
rateLimitRegistry,
conf.RPC.MaxBodyBytes,
true,
)
}
rootHandler = rpcserver.NewRateLimitMiddleware(rootHandler, rateLimitGate)
Comment thread
amir-deris marked this conversation as resolved.
Outdated
Comment thread
amir-deris marked this conversation as resolved.
Outdated
if conf.RPC.IsTLSEnabled() {
go func() {
if err := rpcserver.ServeTLS(
Expand Down
19 changes: 19 additions & 0 deletions sei-tendermint/rpc/jsonrpc/server/http_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,25 @@ func writeRPCResponse(w http.ResponseWriter, rsps ...rpctypes.RPCResponse) {
_, _ = w.Write(body)
}

// writeJSONRPCErrorWithStatus writes a JSON-RPC 2.0 error object for POST /
// admission rejections. reqBody is best-effort parsed for the response id.
func writeJSONRPCErrorWithStatus(w http.ResponseWriter, reqBody []byte, httpStatus int, code rpctypes.ErrorCode, format string, args ...interface{}) {
var req rpctypes.RPCRequest
if len(reqBody) > 0 {
_ = json.Unmarshal(reqBody, &req)
}
resp := req.MakeErrorf(code, format, args...)
body, err := json.Marshal(resp)
if err != nil {
logger.Error("Error encoding RPC response", "err", err)
writeError(w, http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpStatus)
_, _ = w.Write(body)
}

//-----------------------------------------------------------------------------

// recoverAndLogHandler wraps an HTTP handler, adding error logging. If the
Expand Down
95 changes: 95 additions & 0 deletions sei-tendermint/rpc/jsonrpc/server/rate_limit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package server

import (
"context"
"errors"
"io"
"math"
"strings"

"github.com/sei-protocol/sei-chain/ratelimiter"
)

const cometbftRateLimitPlane = "cometbft"

var errInvalidURIMethod = errors.New("invalid URI method")

// RateLimitGate applies per-IP token-bucket rate limiting for CometBFT RPC HTTP
// requests. POST JSON-RPC bodies are parsed with MethodParser before full decode;
// GET URI routes are accounted by path-derived method names.
type RateLimitGate struct {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This file is a near-verbatim copy of evmrpc/rate_limit.go: the struct fields, chargeAdmissionRejection, and CheckPOST (vs. Check) are identical line for line, differing only in the maxBodyBytes <= 0 fallback and the plane string. The fail-closed charging rule — parse error charges MethodInvalid, and an exhausted bucket converts the parse error into a rate-limit rejection — is the load-bearing invariant of the whole design, and it now exists in two places that must be kept in sync by hand.

Both packages already import ratelimiter; hoisting the shared gate there (with plane and body-limit policy as parameters) would make it one invariant instead of a convention. Related: enabled is true at both production call sites (env.go:397, inspect/rpc/rpc.go:84), and NewRateLimitMiddleware already returns inner unchanged for a nil gate — so a nil gate is the single choke point and the flag adds three dead if !g.enabled branches plus a second way to misconfigure the gate. Same point as the earlier review; still applies.

registry *ratelimiter.Registry
parser *ratelimiter.MethodParser
maxBodyBytes int64
enabled bool
plane string
}

// NewRateLimitGate returns a gate for CometBFT RPC HTTP (plane "cometbft").
// registry must be non-nil. maxBodyBytes should match max-body-bytes; non-positive
// values use DefaultConfig().MaxBodyBytes.
func NewRateLimitGate(registry *ratelimiter.Registry, maxBodyBytes int64, enabled bool) *RateLimitGate {
if maxBodyBytes <= 0 {
maxBodyBytes = DefaultConfig().MaxBodyBytes
Comment thread
amir-deris marked this conversation as resolved.
Outdated
}
if maxBodyBytes == math.MaxInt64 {
maxBodyBytes = math.MaxInt64 - 1
}
return &RateLimitGate{
registry: registry,
parser: ratelimiter.NewMethodParser(maxBodyBytes),
maxBodyBytes: maxBodyBytes,
enabled: enabled,
plane: cometbftRateLimitPlane,
}
}

// chargeAdmissionRejection consumes one token for a fail-closed rejection that
// never reaches method parsing (oversize body, read error). Returns true when
// the bucket is exhausted and the caller should respond with HTTP 429.
func (g *RateLimitGate) chargeAdmissionRejection(ctx context.Context, ip string) bool {
if !g.enabled {
return false
}
return !g.registry.Allow(ctx, ip, g.plane, ratelimiter.MethodInvalid)
}

// CheckPOST parses body for JSON-RPC method names and applies per-IP rate limits.
// Parse errors still charge the bucket under ratelimiter.MethodInvalid so
// malformed bodies can't bypass rate limiting.
func (g *RateLimitGate) CheckPOST(ctx context.Context, ip string, body io.Reader) (allowed bool, rejectMethod string, err error) {
if !g.enabled {
return true, "", nil
}

methods, _, parseErr := g.parser.Parse(body)
if parseErr != nil {
if !g.registry.Allow(ctx, ip, g.plane, ratelimiter.MethodInvalid) {
return false, ratelimiter.MethodInvalid, nil
}
return false, "", parseErr
}

if n := len(methods); n > 0 && !g.registry.AllowN(ctx, ip, g.plane, methods[0], n) {
return false, methods[0], nil
}
return true, "", nil
}

// CheckURI applies per-IP rate limits for REST-style GET/HEAD RPC routes.
func (g *RateLimitGate) CheckURI(ctx context.Context, ip, path string) (allowed bool, rejectMethod string, err error) {
if !g.enabled {
return true, "", nil
}
method := strings.TrimPrefix(path, "/")
if method == "" {
if !g.registry.Allow(ctx, ip, g.plane, ratelimiter.MethodInvalid) {
return false, ratelimiter.MethodInvalid, nil
}
return false, "", errInvalidURIMethod
}
if !g.registry.Allow(ctx, ip, g.plane, method) {
return false, method, nil
}
return true, "", nil
}
Loading
Loading