Skip to main content

sui_config/
rpc_config.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::net::SocketAddr;
5use std::time::Duration;
6
7#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)]
8#[serde(rename_all = "kebab-case")]
9pub struct RpcConfig {
10    /// Enable indexing of transactions and objects
11    ///
12    /// This enables indexing of transactions and objects which allows for a slightly richer rpc
13    /// api. There are some APIs which will be disabled/enabled based on this config while others
14    /// (eg GetTransaction) will still be enabled regardless of this config but may return slight
15    /// less data (eg GetTransaction won't return the checkpoint that includes the requested
16    /// transaction).
17    ///
18    /// Defaults to `false`, with indexing and APIs which require indexes being disabled
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub enable_indexing: Option<bool>,
21
22    /// Configure the address to listen on for https
23    ///
24    /// Defaults to `0.0.0.0:9443` if not specified.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub https_address: Option<SocketAddr>,
27
28    /// TLS configuration to use for https.
29    ///
30    /// If not provided then the node will not create an https service.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub tls: Option<RpcTlsConfig>,
33
34    /// Maxumum budget for rendering a Move value into JSON.
35    ///
36    /// This sets the numbers of bytes that we are willing to spend on rendering field names and
37    /// values when rendering a Move value into a JSON value.
38    ///
39    /// Defaults to `1MiB` if not specified.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub max_json_move_value_size: Option<usize>,
42
43    /// Aggregate budget for Move-value JSON rendering across a single response.
44    ///
45    /// Endpoints that render many Move values in one response share this budget
46    /// across all per-item renders, so the response cannot multiply one request
47    /// into hundreds of MiB of materialized `prost_types::Value`. The budget
48    /// covers one checkpoint in `GetCheckpoint`, `ListCheckpoints`, and
49    /// `SubscribeCheckpoints`, and one transaction frame in
50    /// `SubscribeTransactions`. Values rendered after the budget runs out have
51    /// no `json`.
52    ///
53    /// `GetTransaction`, `ListTransactions`, `ListEvents`, and `SubscribeEvents`
54    /// instead render each value with its own `max_json_move_value_size` budget
55    /// and no aggregate cap, so one transaction's event JSON can differ between
56    /// `SubscribeTransactions` and `GetTransaction`.
57    ///
58    /// Defaults to `16 MiB` if not specified.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub max_json_move_value_response_size: Option<usize>,
61
62    /// Configuration for RPC index initialization and bulk loading
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub index_initialization: Option<RpcIndexInitConfig>,
65
66    /// Tunables for the ledger-history list APIs (`list_transactions`,
67    /// `list_events`, `list_checkpoints`). These scan the historical inverted
68    /// indexes, unlike the live object-set listings (`list_owned_objects`,
69    /// `list_dynamic_fields`), so they carry their own time and scan-cost bounds.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub ledger_history: Option<LedgerHistoryConfig>,
72
73    /// Number of consecutive checkpoints a filtered subscription may go without
74    /// producing an item before the server emits a progress-only frame,
75    /// so sparse subscribers always learn their resume point. Defaults to 25
76    /// (~5 seconds at mainnet checkpoint cadence).
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub subscription_watermark_interval: Option<u32>,
79
80    /// Maximum number of concurrent RPC subscriptions. Defaults to 1024.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub subscription_max_subscribers: Option<usize>,
83
84    /// Number of parallel shard tasks that evaluate subscription filters and
85    /// deliver updates. Each subscriber lives on one shard; per-checkpoint
86    /// filter evaluation parallelizes across shards. Defaults to the host's
87    /// available parallelism.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub subscription_shards: Option<u32>,
90
91    /// Configuration for rendering Objects based on the Display standard
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub display: Option<DisplayConfig>,
94
95    /// Maximum age of an RPC connection, in seconds. When a connection
96    /// reaches this age the server sends GOAWAY and stops accepting new
97    /// streams on it; in-flight requests are allowed to complete within
98    /// `max-connection-age-grace-secs`. Bounding connection lifetime is the
99    /// server-side backstop that reclaims streams wedged behind HTTP/2
100    /// flow-control windows that a stalled peer never reopens.
101    ///
102    /// Defaults to 4 hours. Set to `0` to disable (connections then live
103    /// forever).
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub max_connection_age_secs: Option<u64>,
106
107    /// Grace period, in seconds, that in-flight requests are given to
108    /// complete after a connection begins shutting down (its
109    /// `max-connection-age-secs` expired, or the node is stopping). When
110    /// the grace period expires the connection is closed even if streams
111    /// are still open. Set to `0` to close immediately at shutdown.
112    ///
113    /// Defaults to 10 minutes. Has no effect while
114    /// `max-connection-age-secs` is disabled and the node is running.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub max_connection_age_grace_secs: Option<u64>,
117
118    /// Server-side timeout for gRPC requests, in milliseconds. Requests
119    /// that carry a `grpc-timeout` header are bounded by the smaller of the
120    /// two values. The timeout covers a unary request's full execution and
121    /// a streaming request's time to first response; it does not bound the
122    /// lifetime of an established stream.
123    ///
124    /// Defaults to 60 seconds. Set to `0` to disable.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub grpc_timeout_ms: Option<u64>,
127}
128
129const DEFAULT_MAX_CONNECTION_AGE: Duration = Duration::from_secs(4 * 60 * 60);
130const DEFAULT_MAX_CONNECTION_AGE_GRACE: Duration = Duration::from_secs(10 * 60);
131const DEFAULT_GRPC_TIMEOUT: Duration = Duration::from_secs(60);
132
133impl RpcConfig {
134    pub fn enable_indexing(&self) -> bool {
135        self.enable_indexing.unwrap_or(false)
136    }
137
138    pub fn https_address(&self) -> SocketAddr {
139        self.https_address
140            .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 9443)))
141    }
142
143    pub fn tls_config(&self) -> Option<&RpcTlsConfig> {
144        self.tls.as_ref()
145    }
146
147    pub fn max_json_move_value_size(&self) -> usize {
148        self.max_json_move_value_size.unwrap_or(1024 * 1024)
149    }
150
151    pub fn max_json_move_value_response_size(&self) -> usize {
152        self.max_json_move_value_response_size
153            .unwrap_or(16 * 1024 * 1024)
154    }
155
156    pub fn index_initialization_config(&self) -> Option<&RpcIndexInitConfig> {
157        self.index_initialization.as_ref()
158    }
159
160    pub fn ledger_history(&self) -> &LedgerHistoryConfig {
161        const DEFAULT_LEDGER_HISTORY_CONFIG: LedgerHistoryConfig = LedgerHistoryConfig {
162            list_transactions: None,
163            list_events: None,
164            list_checkpoints: None,
165            bitmap_bucket_scan_budget: None,
166            chunk_bucket_scan_budget: None,
167            max_bitmap_filter_literals: None,
168        };
169
170        self.ledger_history
171            .as_ref()
172            .unwrap_or(&DEFAULT_LEDGER_HISTORY_CONFIG)
173    }
174
175    /// Maximum age of an RPC connection; `None` means unlimited.
176    pub fn max_connection_age(&self) -> Option<Duration> {
177        match self.max_connection_age_secs {
178            Some(0) => None,
179            Some(secs) => Some(Duration::from_secs(secs)),
180            None => Some(DEFAULT_MAX_CONNECTION_AGE),
181        }
182    }
183
184    /// Grace period for in-flight requests once a connection begins
185    /// shutting down; `Duration::ZERO` closes immediately.
186    pub fn max_connection_age_grace(&self) -> Duration {
187        self.max_connection_age_grace_secs
188            .map(Duration::from_secs)
189            .unwrap_or(DEFAULT_MAX_CONNECTION_AGE_GRACE)
190    }
191
192    /// Server-side default deadline for gRPC requests; `None` means no
193    /// server-imposed deadline (client `grpc-timeout` headers still apply).
194    pub fn grpc_timeout(&self) -> Option<Duration> {
195        match self.grpc_timeout_ms {
196            Some(0) => None,
197            Some(ms) => Some(Duration::from_millis(ms)),
198            None => Some(DEFAULT_GRPC_TIMEOUT),
199        }
200    }
201
202    /// Validate cross-field invariants. Call once at startup to fail fast on a
203    /// misconfiguration rather than surfacing it per-request.
204    pub fn validate(&self) -> anyhow::Result<()> {
205        self.ledger_history().validate()
206    }
207
208    pub fn display(&self) -> &DisplayConfig {
209        const DEFAULT_DISPLAY_CONFIG: DisplayConfig = DisplayConfig {
210            max_field_depth: None,
211            max_format_nodes: None,
212            max_object_loads: None,
213            max_move_value_depth: None,
214            max_output_size: None,
215        };
216
217        self.display.as_ref().unwrap_or(&DEFAULT_DISPLAY_CONFIG)
218    }
219}
220
221#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)]
222#[serde(rename_all = "kebab-case")]
223pub struct RpcTlsConfig {
224    /// File path to a PEM formatted TLS certificate chain
225    cert: String,
226    /// File path to a PEM formatted TLS private key
227    key: String,
228}
229
230impl RpcTlsConfig {
231    pub fn cert(&self) -> &str {
232        &self.cert
233    }
234
235    pub fn key(&self) -> &str {
236        &self.key
237    }
238}
239
240/// Configuration for RPC index initialization and bulk loading
241#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)]
242#[serde(rename_all = "kebab-case")]
243pub struct RpcIndexInitConfig {
244    /// Override for RocksDB's set_db_write_buffer_size during bulk indexing.
245    /// This is the total memory budget for all column families' memtables.
246    ///
247    /// Defaults to 90% of system RAM if not specified.
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub db_write_buffer_size: Option<usize>,
250
251    /// Override for each column family's write buffer size during bulk indexing.
252    ///
253    /// Defaults to 25% of system RAM divided by max_write_buffer_number if not specified.
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub cf_write_buffer_size: Option<usize>,
256
257    /// Override for the maximum number of write buffers per column family during bulk indexing.
258    /// This value is capped at 32 as an upper bound.
259    ///
260    /// Defaults to a dynamic value based on system RAM if not specified.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub cf_max_write_buffer_number: Option<i32>,
263
264    /// Override for the number of background jobs during bulk indexing.
265    ///
266    /// Defaults to the number of CPU cores if not specified.
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub max_background_jobs: Option<i32>,
269
270    /// Override for the batch size limit during bulk indexing.
271    /// This controls how much data is accumulated in memory before flushing to disk.
272    ///
273    /// Defaults to half the write buffer size or 128MB, whichever is smaller.
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub batch_size_limit: Option<usize>,
276}
277
278#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)]
279#[serde(rename_all = "kebab-case")]
280pub struct DisplayConfig {
281    /// Maximum number of times the parser can recurse into nested structures. Depth does not
282    /// account for all nodes, only nodes that can be contained within themselves.
283    ///
284    /// Defaults to `32` if not specified.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    max_field_depth: Option<usize>,
287
288    /// Maximum number of AST nodes that can be allocated during parsing. This counts all values
289    /// that are instances of AST types (but not, for example, `Vec<T>`).
290    ///
291    /// Defaults to `32768` if not specified.
292    #[serde(skip_serializing_if = "Option::is_none")]
293    max_format_nodes: Option<usize>,
294
295    /// Maximum number of objects that can be loaded during formatting.
296    ///
297    /// Defaults to `8` if not specified.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    max_object_loads: Option<usize>,
300
301    /// Maximum depth to use when converting a rendered Display value to JSON.
302    ///
303    /// Defaults to `32` if not specified.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    max_move_value_depth: Option<usize>,
306
307    /// Maxumum budget for rendering an object based on its Display template.
308    ///
309    /// This sets the numbers of bytes that we are willing to spend on rendering field names and
310    /// values when rendering an object based on its Display template.
311    ///
312    /// Defaults to `1MiB` if not specified.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    max_output_size: Option<usize>,
315}
316
317impl DisplayConfig {
318    pub fn max_field_depth(&self) -> usize {
319        self.max_field_depth.unwrap_or(32)
320    }
321
322    pub fn max_format_nodes(&self) -> usize {
323        self.max_format_nodes.unwrap_or(32768)
324    }
325
326    pub fn max_object_loads(&self) -> usize {
327        self.max_object_loads.unwrap_or(8)
328    }
329
330    pub fn max_move_value_depth(&self) -> usize {
331        self.max_move_value_depth.unwrap_or(32)
332    }
333
334    pub fn max_output_size(&self) -> usize {
335        self.max_output_size.unwrap_or(1024 * 1024)
336    }
337}
338
339#[cfg(test)]
340mod connection_lifecycle_tests {
341    use super::*;
342
343    /// These defaults are availability-relevant: connection age plus grace
344    /// is the server-side backstop that reclaims streams wedged behind
345    /// HTTP/2 flow-control windows, and the gRPC deadline bounds requests
346    /// from clients that set none. Pin them so they cannot silently regress
347    /// to disabled.
348    #[test]
349    fn connection_lifecycle_defaults_are_enabled() {
350        let config = RpcConfig::default();
351        assert_eq!(
352            config.max_connection_age(),
353            Some(Duration::from_secs(4 * 60 * 60))
354        );
355        assert_eq!(
356            config.max_connection_age_grace(),
357            Duration::from_secs(10 * 60)
358        );
359        assert_eq!(config.grpc_timeout(), Some(Duration::from_secs(60)));
360    }
361
362    #[test]
363    fn zero_disables_age_and_timeout() {
364        let config = RpcConfig {
365            max_connection_age_secs: Some(0),
366            max_connection_age_grace_secs: Some(0),
367            grpc_timeout_ms: Some(0),
368            ..Default::default()
369        };
370        assert_eq!(config.max_connection_age(), None);
371        // A zero grace is a valid setting: close immediately at shutdown.
372        assert_eq!(config.max_connection_age_grace(), Duration::ZERO);
373        assert_eq!(config.grpc_timeout(), None);
374    }
375
376    #[test]
377    fn explicit_values_are_used() {
378        let config = RpcConfig {
379            max_connection_age_secs: Some(60),
380            max_connection_age_grace_secs: Some(5),
381            grpc_timeout_ms: Some(1_500),
382            ..Default::default()
383        };
384        assert_eq!(config.max_connection_age(), Some(Duration::from_secs(60)));
385        assert_eq!(config.max_connection_age_grace(), Duration::from_secs(5));
386        assert_eq!(config.grpc_timeout(), Some(Duration::from_millis(1_500)));
387    }
388}
389
390const DEFAULT_LEDGER_HISTORY_METHOD_TIMEOUT_MS: u64 = 5_000;
391const DEFAULT_BITMAP_BUCKET_SCAN_BUDGET: usize = 1_024;
392const DEFAULT_CHUNK_BUCKET_SCAN_BUDGET: usize = 256;
393const DEFAULT_MAX_BITMAP_FILTER_LITERALS: usize = 10;
394// A chunk never evaluates more buckets than the whole request is allowed, so the
395// per-chunk cap must not exceed the per-request budget. Enforced for the
396// defaults here; the accessors clamp configured values the same way.
397const _: () = assert!(DEFAULT_CHUNK_BUCKET_SCAN_BUDGET <= DEFAULT_BITMAP_BUCKET_SCAN_BUDGET);
398
399/// Built-in per-endpoint defaults. These differ per endpoint (e.g. checkpoints
400/// page smaller than transactions, and scan a narrower chunk).
401struct LedgerHistoryMethodDefaults {
402    default_limit_items: u32,
403    max_limit_items: u32,
404    chunk_max: usize,
405}
406
407const LIST_TRANSACTIONS_DEFAULTS: LedgerHistoryMethodDefaults = LedgerHistoryMethodDefaults {
408    default_limit_items: 50,
409    max_limit_items: 500,
410    chunk_max: 32,
411};
412const LIST_EVENTS_DEFAULTS: LedgerHistoryMethodDefaults = LedgerHistoryMethodDefaults {
413    default_limit_items: 50,
414    max_limit_items: 1_000,
415    chunk_max: 32,
416};
417const LIST_CHECKPOINTS_DEFAULTS: LedgerHistoryMethodDefaults = LedgerHistoryMethodDefaults {
418    default_limit_items: 10,
419    max_limit_items: 50,
420    chunk_max: 16,
421};
422
423/// Per-endpoint tunables for one ledger-history list API. Every field is optional
424/// and falls back to a built-in default; see [`ResolvedLedgerHistoryMethodConfig`].
425#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)]
426#[serde(rename_all = "kebab-case")]
427pub struct LedgerHistoryMethodConfig {
428    /// Per-request wall-clock timeout, in milliseconds. Defaults to `5000`.
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub timeout_ms: Option<u64>,
431
432    /// Page size used when a request omits `limit_items`.
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub default_limit_items: Option<u32>,
435
436    /// Upper bound a request's `limit_items` is clamped to.
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub max_limit_items: Option<u32>,
439
440    /// Maximum items materialized per internal scan chunk.
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub chunk_max: Option<usize>,
443}
444
445/// A [`LedgerHistoryMethodConfig`] with all defaults applied.
446#[derive(Clone, Copy, Debug)]
447pub struct ResolvedLedgerHistoryMethodConfig {
448    pub timeout: Duration,
449    pub default_limit_items: u32,
450    pub max_limit_items: u32,
451    pub chunk_max: usize,
452}
453
454impl LedgerHistoryMethodConfig {
455    fn resolve(
456        this: Option<&LedgerHistoryMethodConfig>,
457        defaults: LedgerHistoryMethodDefaults,
458    ) -> ResolvedLedgerHistoryMethodConfig {
459        ResolvedLedgerHistoryMethodConfig {
460            timeout: Duration::from_millis(
461                this.and_then(|c| c.timeout_ms)
462                    .unwrap_or(DEFAULT_LEDGER_HISTORY_METHOD_TIMEOUT_MS),
463            ),
464            default_limit_items: this
465                .and_then(|c| c.default_limit_items)
466                .unwrap_or(defaults.default_limit_items),
467            max_limit_items: this
468                .and_then(|c| c.max_limit_items)
469                .unwrap_or(defaults.max_limit_items),
470            chunk_max: this.and_then(|c| c.chunk_max).unwrap_or(defaults.chunk_max),
471        }
472    }
473}
474
475/// Tunables for the ledger-history list APIs. Per-endpoint knobs live in
476/// the three [`LedgerHistoryMethodConfig`] fields; the remaining knobs are global across
477/// all three. Every field is optional and falls back to a built-in default.
478#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)]
479#[serde(rename_all = "kebab-case")]
480pub struct LedgerHistoryConfig {
481    /// Per-endpoint tunables for `list_transactions`.
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub list_transactions: Option<LedgerHistoryMethodConfig>,
484
485    /// Per-endpoint tunables for `list_events`.
486    #[serde(skip_serializing_if = "Option::is_none")]
487    pub list_events: Option<LedgerHistoryMethodConfig>,
488
489    /// Per-endpoint tunables for `list_checkpoints`.
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub list_checkpoints: Option<LedgerHistoryMethodConfig>,
492
493    /// Total evaluated-bucket budget for one filtered request, shared by all
494    /// three list APIs. Exhausting it ends the query with `SCAN_LIMIT` and a
495    /// resume cursor, bounding the worst-case scan cost of a sparse filter.
496    ///
497    /// Defaults to `1024` if not specified.
498    #[serde(skip_serializing_if = "Option::is_none")]
499    pub bitmap_bucket_scan_budget: Option<usize>,
500
501    /// Per-chunk evaluated-bucket cap. A chunk that hits this while the request
502    /// budget remains emits a progress watermark and resumes in the next chunk,
503    /// so a long sparse scan reports incremental progress. Clamped to
504    /// `bitmap_bucket_scan_budget`.
505    ///
506    /// Defaults to `256` if not specified.
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub chunk_bucket_scan_budget: Option<usize>,
509
510    /// Maximum total filter literals (bitmap dimensions) accepted in one filtered
511    /// request, across all DNF terms. Each literal becomes one bitmap leaf, so
512    /// this bounds a single filter's scan fanout. Must not exceed
513    /// `bitmap_bucket_scan_budget` (see [`LedgerHistoryConfig::validate`]).
514    ///
515    /// Defaults to `10` if not specified.
516    #[serde(skip_serializing_if = "Option::is_none")]
517    pub max_bitmap_filter_literals: Option<usize>,
518}
519
520impl LedgerHistoryConfig {
521    pub fn list_transactions(&self) -> ResolvedLedgerHistoryMethodConfig {
522        LedgerHistoryMethodConfig::resolve(
523            self.list_transactions.as_ref(),
524            LIST_TRANSACTIONS_DEFAULTS,
525        )
526    }
527
528    pub fn list_events(&self) -> ResolvedLedgerHistoryMethodConfig {
529        LedgerHistoryMethodConfig::resolve(self.list_events.as_ref(), LIST_EVENTS_DEFAULTS)
530    }
531
532    pub fn list_checkpoints(&self) -> ResolvedLedgerHistoryMethodConfig {
533        LedgerHistoryMethodConfig::resolve(
534            self.list_checkpoints.as_ref(),
535            LIST_CHECKPOINTS_DEFAULTS,
536        )
537    }
538
539    pub fn bitmap_bucket_scan_budget(&self) -> usize {
540        self.bitmap_bucket_scan_budget
541            .unwrap_or(DEFAULT_BITMAP_BUCKET_SCAN_BUDGET)
542    }
543
544    pub fn chunk_bucket_scan_budget(&self) -> usize {
545        self.chunk_bucket_scan_budget
546            .unwrap_or(DEFAULT_CHUNK_BUCKET_SCAN_BUDGET)
547            .min(self.bitmap_bucket_scan_budget())
548    }
549
550    pub fn max_bitmap_filter_literals(&self) -> usize {
551        self.max_bitmap_filter_literals
552            .unwrap_or(DEFAULT_MAX_BITMAP_FILTER_LITERALS)
553    }
554
555    /// Reject configurations that cannot make forward progress. Each filter
556    /// literal becomes one bitmap leaf that must fetch at least one bucket to
557    /// emit its first watermark; if the per-request budget is below the literal
558    /// cap a `SCAN_LIMIT` can fire before any merged watermark reaches the wire,
559    /// leaving the client a cursorless `QueryEnd` it cannot resume from. Mirrors
560    /// the archival/BigTable side's `LedgerHistoryConfig::validate`.
561    pub fn validate(&self) -> anyhow::Result<()> {
562        anyhow::ensure!(
563            self.bitmap_bucket_scan_budget() >= self.max_bitmap_filter_literals(),
564            "ledger_history.bitmap_bucket_scan_budget ({}) must be >= \
565             max_bitmap_filter_literals ({}) so every filter leaf gets at least one \
566             bucket before SCAN_LIMIT",
567            self.bitmap_bucket_scan_budget(),
568            self.max_bitmap_filter_literals(),
569        );
570        Ok(())
571    }
572}