Skip to content

rpc: gate REST and bound listener and scan costs - #878

Open
rearden-grok[bot] wants to merge 7 commits into
net/peer-lifecyclefrom
rpc/rest-electrum
Open

rearden-grok[bot] wants to merge 7 commits into
net/peer-lifecyclefrom
rpc/rest-electrum

Conversation

@rearden-grok

@rearden-grok rearden-grok Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Why

REST on the RPC port answered without a flag and shared the RPC work queue, taking a slot before the body was read. A long-poll then held that slot for the whole wait. Silent-payment unsubscribe wrote the scan secret into the API log, and a nonzero start height scanned from that height through the tip.

What

  • /rest/ is off unless --rest or rest= is set. It has its own queue, and the body is read before that permit. NixOS services.rbitcoin.rpc.rest (default false) passes --rest only when RPC is also on.
  • RPC waits and getblocktemplate long-poll stop at two minutes. The listener caps connections at 256 and uses that same request timeout. The work-queue permit is taken after the wait, not during it.
  • API logs strip xprv / tprv / yprv / zprv key material and a silent-payment scan secret.
  • A silent-payment scan stays inside the existing 256-block window, including when start is nonzero, and stops when the client hangs up. --sh-index is not required.

Stacked on net/peer-lifecycle.

rearden-grok Bot added 7 commits October 2, 2026 20:25
Unauthenticated /rest/ was on every RPC listener and took an RPC work-queue
permit before the body was read. The flag defaults off. The body is read
first, and REST uses its own queue.
A nonzero start height walked from that height through the tip, and the
scan kept running after the client hung up. Every start now stays inside
the existing 256-block window, and each chunk checks the socket once
before it runs.
The chunk count was only read by the test, so a library build rejected
it. The subscribe path now uses that count: if the client hung up
before any chunk, it does not write a history notification.
A waitfor* call held the only work-queue permit for the whole sleep, so
other RPC calls got 503. The permit is taken after the wait. Requested
timeouts and getblocktemplate long-poll stop at two minutes, and the
listener uses a 256-connection cap and the same request timeout.
Clippy rejects `&waker` because `Waker::noop` already returns a reference.
Silent-payment unsubscribe and scantxoutset logged the scan key and
xprv/tprv text. api_call strips those before the trace line and the
JSONL file, so every caller is covered.
The index links 063-066 for REST, log redaction, the scan window, and
RPC wait limits.
@rearden-grok
rearden-grok Bot force-pushed the net/peer-lifecycle branch from b60f86f to 787b4d9 Compare October 3, 2026 03:35
@rearden-grok
rearden-grok Bot force-pushed the rpc/rest-electrum branch from e41fb19 to a2a607b Compare October 3, 2026 03:35

@rearden-grok rearden-grok Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Summary

Gating REST, giving it its own queue, reading the body before that permit, clamping silent-payment scans to the existing 256-block window, and redacting scan secrets and xprv/tprv material in api_call are the right fixes. NixOS defaults rpc.rest to false and only passes --rest when RPC is also on. The new tower layers do not implement the accept cap or the wait result they describe: a full-length waitfor* / getblocktemplate long-poll is turned into an empty HTTP 408, and ConcurrencyLimitLayer does not cap accepts. The REST default also leaves the cross-surface REST pins returning 404.

Issue counts by severity

  • bugs: 3
  • suggestions: 1
  • nits: 0

.route("/rest/{*path}", get(rest_entry).post(rest_entry))
.layer(DefaultBodyLimit::max(RPC_MAX_HTTP_BODY))
.layer(from_fn_with_state(state.clone(), reject_unauthorized))
.layer(TimeoutLayer::with_status_code(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[bug] TimeoutLayer starts its 120s clock at call, which is before satisfy_http_wait arms the same RPC_WAIT_TIMEOUT_MS deadline, and on expiry it returns an empty 408 and drops the handler future. tower_http's timeout poll checks the sleep first, so a waitfor* or getblocktemplate long-poll that runs out the cap never reaches exec_http_rpc. The handler was written to return true at that deadline and then emit the JSON-RPC tip or template; clients now get a body-less 408 instead. The same drop detaches spawn_blocking (JoinHandle drop does not abort) while releasing the work-queue permit, so a method that is already inside the blocking task keeps running after the slot is free.

Suggestion: Do not wrap the router in TimeoutLayer. Keep the two-minute cap inside satisfy_http_wait so the method still returns a JSON-RPC body. If a slow header or body needs a bound, use a read timeout that ends when the request is fully received, not a timeout of the whole service future. If a handler timeout stays, it has to outlive the wait cap, and dropping the handler must not detach the blocking task while freeing the permit.

StatusCode::REQUEST_TIMEOUT,
std::time::Duration::from_millis(crate::methods::RPC_WAIT_TIMEOUT_MS),
))
.layer(ConcurrencyLimitLayer::new(RPC_MAX_CONNECTIONS))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[bug] RPC_MAX_CONNECTIONS is documented as an accept cap copied from the Electrum listener, but ConcurrencyLimitLayer is not that. axum::serve accepts the TCP connection and spawns handle_connection before this layer runs. Hyper reads the request head without calling tower poll_ready; only TowerToHyperService's oneshot then waits on the semaphore. Extra connections are not dropped, and that wait sits outside TimeoutLayer (the timeout starts in call, after the permit). Electrum try_acquire_owneds and drops the stream at max_connections. This layer only queues in-flight requests that already sent headers, and it holds one of those 256 slots for the whole long-poll.

Suggestion: Cap accepts the way Electrum does: a shared semaphore around accept, try_acquire_owned, and drop the socket when it is full. Do not use ConcurrencyLimitLayer as that cap. If the in-flight limit stays, put the timeout outside it or the queued request never hits the two-minute bound.

async fn rest_entry(State(state): State<AppState>, req: axum::extract::Request) -> Response {
let _permit = match state.work_queue.try_acquire() {
if !state.rest {
return StatusCode::NOT_FOUND.into_response();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[bug] REST is 404 unless RpcConfig.rest is set, and RpcOpts::rest defaults to false. esplora_broadcast_visible_in_rpc_and_electrum in crates/rbitcoin-test/tests/cross_surface.rs sets cfg.rpc.listen and then calls pin_rest_chaininfo_and_blockhash, pin_rest_headers, pin_rest_block_and_tx, pin_rest_mempool_and_utxos, and pin_rest_deployment_and_filter, all expecting HTTP 200. Nothing in that journey sets rpc.rest, so those pins 404. Docs and the NixOS module were updated; this in-repo REST journey was not.

Suggestion: Set cfg.rpc.rest = true on that journey (it is the REST pin, so it should opt in). Any other node spawn that GETs /rest/ on the RPC listener needs the same flag.

err: Option<&str>,
) {
let params = compact_params(params);
let params = compact_params(&redact_secrets(method, params));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[suggestion] api_call now always runs redact_ext_privkeys, which allocates and copies the entire params string, and then compact_params copies again. exec_one already built that string with serde_json::to_string and calls api_call on the blocking RPC thread while rpc_post still holds the work-queue permit. compact_params used to keep at most 384 bytes; a submitblock body (up to the 2 MiB HTTP cap) is now copied in full before truncation. Redaction itself is in the right place (before the trace line and the JSONL write) and covers unsubscribe via method.contains("silentpayment") plus xprv/tprv/yprv/zprv on every method.

Suggestion: Scan for the tags and a quoted 64-hex secret, and allocate a second string only when one matches. Truncate to PARAMS_MAX before the copy when the line will not contain a secret past the cap, so a large RPC body is not duplicated on the work-queue thread.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants