-
Notifications
You must be signed in to change notification settings - Fork 887
PLT-819: Wire fail-closed rate limiter + MethodParser into EVM JSON-RPC HTTP (:8545) #3779
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
eca7830
717a425
f6c1bb5
7d2027d
84486e8
4d7801b
71af9f4
7267e39
352475f
459b3ba
b7e1d18
856f4b4
6fafc67
c7edbde
919b670
9054c11
1d004a3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import ( | |
| "time" | ||
|
|
||
| "github.com/ethereum/go-ethereum/rpc" | ||
| "github.com/sei-protocol/sei-chain/ratelimiter" | ||
| servertypes "github.com/sei-protocol/sei-chain/sei-cosmos/server/types" | ||
| "github.com/sei-protocol/sei-chain/sei-db/ledger_db/receipt" | ||
| "github.com/spf13/cast" | ||
|
|
@@ -236,12 +237,26 @@ type Config struct { | |
| TraceBakeSnapshotWindow int64 `mapstructure:"trace_bake_snapshot_window"` // recent snapshots to keep (default 64) | ||
|
|
||
| // IPRateLimitRPS is the per-IP sustained request rate in requests/second. | ||
| // Zero disables per-IP rate limiting (all requests pass through). | ||
| // 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. | ||
| // 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 | ||
| // batch_request_limit 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 EVM 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"` | ||
|
|
||
| // BatchRequestLimit is the maximum number of requests allowed in a single | ||
| // JSON-RPC batch (HTTP and WebSocket). Set to 0 to disable the limit. | ||
| BatchRequestLimit int `mapstructure:"batch_request_limit"` | ||
|
|
@@ -252,8 +267,8 @@ type Config struct { | |
|
|
||
| // MaxRequestBodyBytes is the maximum size, in bytes, of a single HTTP | ||
| // JSON-RPC request body. Requests larger than this are rejected (HTTP 413) | ||
| // before the body is buffered or JSON-decoded. 0 uses the go-ethereum | ||
| // default (5 MiB). | ||
| // before the body is buffered or JSON-decoded, including at the rate limiter | ||
| // method-extraction layer. 0 uses the go-ethereum default (5 MiB). | ||
| MaxRequestBodyBytes int64 `mapstructure:"max_request_body_bytes"` | ||
|
|
||
| // MaxConcurrentRequestBytes bounds the total size, in bytes, of HTTP | ||
|
|
@@ -270,6 +285,8 @@ type Config struct { | |
| MaxOpenConnections int `mapstructure:"max_open_connections"` | ||
| } | ||
|
|
||
| const defaultBatchRequestLimit = 1000 | ||
|
|
||
| var DefaultConfig = Config{ | ||
| HTTPEnabled: true, | ||
| HTTPPort: 8545, | ||
|
|
@@ -321,8 +338,10 @@ var DefaultConfig = Config{ | |
| TraceBakeUseSnapshot: false, | ||
| TraceBakeSnapshotWindow: 64, | ||
| IPRateLimitRPS: 200, | ||
| IPRateLimitBurst: 400, | ||
| BatchRequestLimit: 1000, | ||
| IPRateLimitBurst: defaultBatchRequestLimit, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] The "should be at least The upgrade path makes this concrete: existing nodes already have Suggest validating at startup (error or warn + clamp There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [blocker] This changes the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] This raises the default per-IP burst 2.5× (400 → 1000) for every deployment, not just ones that enable rate limiting — a security-relevant relaxation being made to satisfy the new burst ≥ If the per-batch charge were capped (see the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] Aliasing the burst default to |
||
| RateLimitingEnabled: false, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [blocker] The PR description says Please reconcile one way or the other. If shipping dark is intentional, fix the description and state which release flips it; if the intent was on-by-default, this line and the test need to change. Right now a reviewer trusting the description would believe |
||
| TrustedProxyCIDRs: nil, | ||
| BatchRequestLimit: defaultBatchRequestLimit, | ||
| BatchResponseMaxSize: 25 * 1000 * 1000, // 25MB | ||
| MaxRequestBodyBytes: 5 * 1024 * 1024, // 5 MiB (matches go-ethereum rpc default body limit) | ||
| MaxConcurrentRequestBytes: 128 * 1024 * 1024, // 128 MiB of request bodies admitted concurrently | ||
|
|
@@ -377,6 +396,8 @@ const ( | |
| flagTraceBakeSnapshotWindow = "evm.trace_bake_snapshot_window" | ||
| flagIPRateLimitRPS = "evm.ip_rate_limit_rps" | ||
| flagIPRateLimitBurst = "evm.ip_rate_limit_burst" | ||
| flagRateLimitingEnabled = "evm.rate_limiting_enabled" | ||
| flagTrustedProxyCIDRs = "evm.trusted_proxy_cidrs" | ||
| flagBatchRequestLimit = "evm.batch_request_limit" | ||
| flagBatchResponseMaxSize = "evm.batch_response_max_size" | ||
| flagMaxRequestBodyBytes = "evm.max_request_body_bytes" | ||
|
|
@@ -631,6 +652,16 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { | |
| return cfg, err | ||
| } | ||
| } | ||
| if v := opts.Get(flagRateLimitingEnabled); v != nil { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [blocker] These two new reads need rows in the {Key: "evm.rate_limiting_enabled", Path: "RateLimitingEnabled", Cast: configtest.CastBool, Checked: true},
{Key: "evm.trusted_proxy_cidrs", Path: "TrustedProxyCIDRs", Cast: configtest.CastStringSlice, Checked: true},Without them |
||
| if cfg.RateLimitingEnabled, err = cast.ToBoolE(v); err != nil { | ||
| return cfg, err | ||
| } | ||
| } | ||
| if v := opts.Get(flagTrustedProxyCIDRs); v != nil { | ||
| if cfg.TrustedProxyCIDRs, err = cast.ToStringSliceE(v); err != nil { | ||
| return cfg, err | ||
| } | ||
| } | ||
|
Comment on lines
+655
to
+664
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note for later: as part of the configuration manager project we are creating comprehensive test coverage that asserts the behavior of config values. I know you have added lots of new config. It would be good to have a follow-up action item on these to make sure we have full test coverage over these values. I've created an AGENTS.md in #3816 that should make this simple
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for feedback. Created https://linear.app/seilabs/issue/PLT-874/ to make sure we have full coverage for the newly added config fields. |
||
| if v := opts.Get(flagBatchRequestLimit); v != nil { | ||
| if cfg.BatchRequestLimit, err = cast.ToIntE(v); err != nil { | ||
| return cfg, err | ||
|
|
@@ -659,6 +690,14 @@ func ReadConfig(opts servertypes.AppOptions) (Config, error) { | |
| return cfg, fmt.Errorf("%s must be >= 0 (0 disables the limit), got %d", flagMaxOpenConnections, cfg.MaxOpenConnections) | ||
| } | ||
| } | ||
| if cfg.RateLimitingEnabled && cfg.IPRateLimitBurst > 0 && cfg.BatchRequestLimit > 0 && | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] Good guard. Worth adding a companion warning for the bigger operational footgun: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] This turns a previously-valid config into a hard boot failure, and the affected population is large: every existing node has There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] Good hard failure with an actionable message. One note on charging model: with |
||
| cfg.IPRateLimitBurst < cfg.BatchRequestLimit { | ||
| return cfg, fmt.Errorf( | ||
| "%s (%d) must be >= %s (%d): the rate limiter charges one token per batch element, "+ | ||
| "so a lower burst would permanently reject any full-size batch", | ||
| flagIPRateLimitBurst, cfg.IPRateLimitBurst, flagBatchRequestLimit, cfg.BatchRequestLimit, | ||
| ) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Burst check ignores zero RPSLow Severity The startup check that Reviewed by Cursor Bugbot for commit 9054c11. Configure here. |
||
| } | ||
|
Comment on lines
+693
to
+700
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 The new burst-vs-batch validation in Extended reasoning...The bug. Why the check is incomplete. Why this contradicts documented behavior. This same PR's new doc comment on Why nothing catches it today. The new test Step-by-step proof.
Fix. Add Severity. This is a real correctness gap in the new validation and it does block node startup for an operator using a mode the PR itself documents as valid — but it's narrow: rate limiting ships disabled by default, the default |
||
| return cfg, nil | ||
| } | ||
|
|
||
|
|
@@ -682,6 +721,15 @@ func normalizeNativeTracerNames(flagName string, names []string) ([]string, erro | |
| return out, nil | ||
| } | ||
|
|
||
| // RateLimiterConfig builds the ratelimiter.Config used by EVM JSON-RPC admission. | ||
| func (c Config) RateLimiterConfig() ratelimiter.Config { | ||
| return ratelimiter.Config{ | ||
| RPS: c.IPRateLimitRPS, | ||
| Burst: c.IPRateLimitBurst, | ||
| TrustedProxyCIDRs: c.TrustedProxyCIDRs, | ||
| } | ||
| } | ||
|
|
||
| // ConfigTemplate defines the TOML configuration template for EVM RPC | ||
| const ConfigTemplate = ` | ||
| ############################################################################### | ||
|
|
@@ -902,12 +950,28 @@ trace_bake_use_snapshot = {{ .EVM.TraceBakeUseSnapshot }} | |
| trace_bake_snapshot_window = {{ .EVM.TraceBakeSnapshotWindow }} | ||
|
|
||
| # ip_rate_limit_rps is the per-IP sustained request rate in requests/second. | ||
| # Set to 0 to disable per-IP rate limiting (all requests pass through). | ||
| # Set to 0 to disable per-IP throttling (no HTTP 429). Does not bypass the | ||
| # admission middleware; set rate_limiting_enabled = false for a full bypass. | ||
| ip_rate_limit_rps = {{ .EVM.IPRateLimitRPS }} | ||
|
|
||
| # ip_rate_limit_burst is the maximum per-IP burst above the sustained rate. | ||
| # Set to 0 to disable per-IP throttling (same effect as ip_rate_limit_rps = 0). | ||
| # Must be at least batch_request_limit when both are positive and rate_limiting_enabled | ||
| # is true (one token is charged per batch element) - enforced at startup. | ||
| ip_rate_limit_burst = {{ .EVM.IPRateLimitBurst }} | ||
|
|
||
| # rate_limiting_enabled is the master switch for the rate-limit admission | ||
| # middleware on the EVM HTTP plane (:8545). When false, requests bypass method | ||
| # extraction and all rejections from that layer (HTTP 400/413/429). | ||
| rate_limiting_enabled = {{ .EVM.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 — set | ||
| # this to your ingress/LB CIDRs when the node sits behind a reverse proxy. | ||
| # Do NOT use the full RFC-1918 private ranges unless every address in them is | ||
| # your own controlled infrastructure. | ||
| trusted_proxy_cidrs = [{{- range $i, $c := .EVM.TrustedProxyCIDRs }}{{- if $i }}, {{ end }}"{{ $c }}"{{- end }}] | ||
|
|
||
| # batch_request_limit is the maximum number of requests allowed in a single | ||
| # JSON-RPC batch (HTTP and WebSocket). Set to 0 to disable the limit. | ||
| batch_request_limit = {{ .EVM.BatchRequestLimit }} | ||
|
|
@@ -918,7 +982,8 @@ batch_response_max_size = {{ .EVM.BatchResponseMaxSize }} | |
|
|
||
| # max_request_body_bytes is the maximum size, in bytes, of a single HTTP | ||
| # JSON-RPC request body. Larger requests are rejected (HTTP 413) before the body | ||
| # is buffered or JSON-decoded. Set to 0 to use the default (5 MiB). | ||
| # is buffered or JSON-decoded, including at the rate limiter method-extraction | ||
| # layer. Set to 0 to use the default (5 MiB). | ||
| max_request_body_bytes = {{ .EVM.MaxRequestBodyBytes }} | ||
|
|
||
| # max_concurrent_request_bytes bounds the total size, in bytes, of HTTP JSON-RPC | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,6 +44,8 @@ type opts struct { | |
| workerQueueSize interface{} | ||
| ipRateLimitRPS interface{} | ||
| ipRateLimitBurst interface{} | ||
| rateLimitingEnabled interface{} | ||
| trustedProxyCIDRs interface{} | ||
| batchRequestLimit interface{} | ||
| batchResponseMaxSize interface{} | ||
| maxRequestBodyBytes interface{} | ||
|
|
@@ -177,6 +179,12 @@ func (o *opts) Get(k string) interface{} { | |
| if k == "evm.ip_rate_limit_burst" { | ||
| return o.ipRateLimitBurst | ||
| } | ||
| if k == "evm.rate_limiting_enabled" { | ||
| return o.rateLimitingEnabled | ||
| } | ||
| if k == "evm.trusted_proxy_cidrs" { | ||
| return o.trustedProxyCIDRs | ||
| } | ||
| if k == "evm.batch_request_limit" { | ||
| return o.batchRequestLimit | ||
| } | ||
|
|
@@ -247,7 +255,9 @@ func getDefaultOpts() opts { | |
| 32, | ||
| 1000, | ||
| 200.0, | ||
| 400, | ||
| 1000, | ||
| nil, | ||
| nil, | ||
| 1000, | ||
| 25 * 1000 * 1000, | ||
| int64(5 * 1024 * 1024), | ||
|
|
@@ -632,3 +642,61 @@ func TestReadConfigMaxSubscriptionsLogs(t *testing.T) { | |
| _, err = config.ReadConfig(&opts) | ||
| require.Error(t, err) | ||
| } | ||
|
|
||
| func TestReadConfigRateLimiting(t *testing.T) { | ||
| cfg, err := config.ReadConfig(&opts{}) | ||
| require.NoError(t, err) | ||
| require.False(t, cfg.RateLimitingEnabled) | ||
| require.Nil(t, cfg.TrustedProxyCIDRs) | ||
| require.Equal(t, config.DefaultConfig.IPRateLimitRPS, cfg.IPRateLimitRPS) | ||
| require.Equal(t, config.DefaultConfig.IPRateLimitBurst, cfg.IPRateLimitBurst) | ||
| require.GreaterOrEqual(t, cfg.IPRateLimitBurst, cfg.BatchRequestLimit) | ||
|
|
||
| o := getDefaultOpts() | ||
| o.rateLimitingEnabled = false | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] This sets |
||
| o.trustedProxyCIDRs = []string{"10.0.0.0/8", "203.0.113.0/24"} | ||
| cfg, err = config.ReadConfig(&o) | ||
| require.NoError(t, err) | ||
| require.False(t, cfg.RateLimitingEnabled) | ||
| require.Equal(t, []string{"10.0.0.0/8", "203.0.113.0/24"}, cfg.TrustedProxyCIDRs) | ||
|
|
||
| rlCfg := cfg.RateLimiterConfig() | ||
| require.Equal(t, cfg.IPRateLimitRPS, rlCfg.RPS) | ||
| require.Equal(t, cfg.IPRateLimitBurst, rlCfg.Burst) | ||
| require.Equal(t, cfg.TrustedProxyCIDRs, rlCfg.TrustedProxyCIDRs) | ||
| } | ||
|
|
||
| func TestReadConfigRateLimitingBurstBelowBatchLimitRejected(t *testing.T) { | ||
| // A burst below batch_request_limit would permanently reject full-size | ||
| // batches (one token is charged per batch element), so ReadConfig refuses | ||
| // it outright rather than letting an operator discover it in production. | ||
| o := getDefaultOpts() | ||
| o.rateLimitingEnabled = true | ||
| o.ipRateLimitBurst = 400 | ||
| o.batchRequestLimit = 1000 | ||
| _, err := config.ReadConfig(&o) | ||
| require.Error(t, err) | ||
|
|
||
| // Equal burst and batch limit is allowed. | ||
| o.ipRateLimitBurst = 1000 | ||
| _, err = config.ReadConfig(&o) | ||
| require.NoError(t, err) | ||
|
|
||
| // The check is skipped when rate limiting is disabled... | ||
| o.rateLimitingEnabled = false | ||
| o.ipRateLimitBurst = 400 | ||
| _, err = config.ReadConfig(&o) | ||
| require.NoError(t, err) | ||
|
|
||
| // ...when the token bucket itself is disabled (burst <= 0)... | ||
| o.rateLimitingEnabled = true | ||
| o.ipRateLimitBurst = 0 | ||
| _, err = config.ReadConfig(&o) | ||
| require.NoError(t, err) | ||
|
|
||
| // ...and when the batch limit is unbounded (batch_request_limit <= 0). | ||
| o.ipRateLimitBurst = 400 | ||
| o.batchRequestLimit = 0 | ||
| _, err = config.ReadConfig(&o) | ||
| require.NoError(t, err) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package evmrpc | ||
|
|
||
| import ( | ||
| "context" | ||
| "io" | ||
| "math" | ||
|
|
||
| "github.com/sei-protocol/sei-chain/ratelimiter" | ||
| ) | ||
|
|
||
| // RateLimitGate applies per-IP token-bucket rate limiting after extracting JSON-RPC | ||
| // method names from the entire request body (bounded by max_request_body_bytes). | ||
| // When enabled, method extraction and fail-closed rejection (HTTP 400/413) run even | ||
| // if the registry is configured with RPS or burst zero (no HTTP 429 only). | ||
| type RateLimitGate struct { | ||
| registry *ratelimiter.Registry | ||
| parser *ratelimiter.MethodParser | ||
| maxBodyBytes int64 | ||
| enabled bool | ||
| plane string | ||
| } | ||
|
|
||
| // NewRateLimitGate returns a gate for the given plane ("evm"). registry must be non-nil. | ||
| // maxBodyBytes is the same cap as max_request_body_bytes; non-positive values use | ||
| // defaultMaxRequestBodyBytes. | ||
| func NewRateLimitGate(registry *ratelimiter.Registry, maxBodyBytes int64, enabled bool, plane string) *RateLimitGate { | ||
| if maxBodyBytes <= 0 { | ||
| maxBodyBytes = defaultMaxRequestBodyBytes | ||
| } | ||
| if maxBodyBytes == math.MaxInt64 { | ||
| maxBodyBytes = math.MaxInt64 - 1 | ||
| } | ||
| return &RateLimitGate{ | ||
| registry: registry, | ||
| parser: ratelimiter.NewMethodParser(maxBodyBytes), | ||
| maxBodyBytes: maxBodyBytes, | ||
| enabled: enabled, | ||
| plane: plane, | ||
| } | ||
| } | ||
|
|
||
| // Check parses body for JSON-RPC method names and applies per-IP rate limits. | ||
| // rejectMethod is the method that exhausted the bucket when allowed=false. | ||
| // Parse errors still charge the bucket under ratelimiter.MethodInvalid so | ||
| // malformed bodies can't bypass rate limiting; if that exhausts the bucket, | ||
| // Check reports a rate-limit rejection instead of returning the parse error. | ||
| func (g *RateLimitGate) Check(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 | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| for _, method := range methods { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [blocker] One token is charged per batch element, but the defaults are Also note that the tokens consumed by the prefix methods are not refunded on rejection, so a client retrying a large batch keeps draining its bucket and can never make progress. Suggestions: use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] Batch charging is not atomic: tokens for elements This interacts badly with the fact that Prefer reserving There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] Two issues with charging one token per element in a loop:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] The token loop itself is self-bounding (it breaks at the first denial, so at most |
||
| if !g.registry.Allow(ctx, ip, g.plane, method) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] Tokens consumed by earlier batch elements are not refunded when a later element is rejected. A 1000-element batch whose 999th element exhausts the bucket burns 998 tokens and returns 429 having served nothing — the client is throttled harder than the configured RPS implies, and an attacker can convert one request into a full bucket drain plus zero work. Also note this charges before go-ethereum's Consider reserving atomically up-front instead of per-element — e.g. |
||
| return false, method, nil | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Batch reject burns prior tokensMedium Severity
Reviewed by Cursor Bugbot for commit c7edbde. Configure here. |
||
| } | ||
| return true, "", nil | ||
|
claude[bot] marked this conversation as resolved.
|
||
| } | ||


Uh oh!
There was an error while loading. Please reload this page.