Skip to content

validation: cap mainchain RPC timeout during peg-in validation (+ -mainchainrpctimeout=0 help text fix) - #1608

Open
GracedEternalKingCabbageMan wants to merge 3 commits into
ElementsProject:masterfrom
GracedEternalKingCabbageMan:validatepegin-rpc-timeout-hardening
Open

GracedEternalKingCabbageMan wants to merge 3 commits into
ElementsProject:masterfrom
GracedEternalKingCabbageMan:validatepegin-rpc-timeout-hardening

Conversation

@GracedEternalKingCabbageMan

@GracedEternalKingCabbageMan GracedEternalKingCabbageMan commented Oct 7, 2026 •

Copy link
Copy Markdown

validation: cap mainchain RPC timeout during peg-in validation

This PR contains two commits:

  1. rpc: correct the -mainchainrpctimeout=0 help text — a one-line documentation fix.
  2. validation: cap mainchain RPC timeout during peg-in validation — the behavior change.

Problem

Peg-in validation (-validatepegin, default-on for chains with a federated peg such as Liquid)
issues synchronous JSON-RPC calls to the configured mainchain daemon, from code paths that
hold cs_main (and, for mempool acceptance, mempool.cs as well):

Path Call Lock context Cost per attempt
Block connection: Chainstate::ConnectTip → CheckPeginRipeness getblockheader AssertLockHeld(cs_main) ~1 timeout (returns at first depth failure)
Consensus::CheckTxInputs, called from both block connection (ConnectBlock) and mempool acceptance (MemPoolAccept::PreChecks) getblockheader cs_main (+ mempool.cs at acceptance) per peg-in input
Mempool acceptance: MemPoolAccept::PreChecks → CheckPeginSubsidyAndMinimum getrawtransaction EXCLUSIVE_LOCKS_REQUIRED(cs_main, m_pool.cs) per peg-in input
Mempool epoch ejection: CTxMemPool::removeForBlock getblockheader connect path per peg-in input

All of these go through CallMainChainRPC, which applies -mainchainrpctimeout (default
900 seconds) to each call, with a fresh blocking TCP connection per call and no caching of
results. (The libevent timeout is per phase — connect and read are timed separately — so the
worst case per call is roughly twice the configured value, plus a blocking DNS lookup if
-mainchainrpchost is a hostname.)

If the local mainchain daemon accepts TCP connections but stops answering requests (it is itself
stalled, overloaded, or otherwise wedged), every one of these calls blocks for up to 15 minutes
while the locks are held — freezing block validation, mempool acceptance, and most RPC for the
duration. ConnectTip even logs that the chain "will not grow until this is remedied", so this
coupling is by design; what is missing is a bound on how long a single unresponsive-daemon
episode can stall validation per attempt.

What happens on failure (unchanged by this PR, only sooner)

  • CheckPeginRipeness (ConnectTip): IsConfirmedBitcoinBlock returns false → depth_failed
    → ConnectTip stalls and retries. This is the deliberate stall-and-retry machinery.
  • Mempool acceptance (CheckTxInputs): the transaction is rejected (TX_WITNESS_MUTATED,
    bad-pegin-witness); re-broadcast recovers it.
  • Mempool acceptance (CheckPeginSubsidyAndMinimum): the timeout surfaces as
    CConnectionFailed thrown out of AcceptToMemoryPool (caught and logged by ProcessMessages
    for P2P, returned as a JSON error for RPC), exactly as it does after 900 s today.
  • Epoch ejection (removeForBlock): the peg-in is evicted; re-broadcast recovers it.
  • ConnectBlock (CheckTxInputs with depth checking): a failure is a BLOCK_CONSENSUS
    failure and the block is marked BLOCK_FAILED_VALID until reconsiderblock. This is the
    pre-existing elementsd stuck syncing at block 2137819 #1198
    (daemon goes away between the ripeness check and ConnectBlock). The cap
    does not introduce that hazard, but it does turn a mainchain response that takes longer than
    the cap (30–900 s today) from a success into a failure on that path. See "Notes for reviewers".

So on every path except ConnectBlock, blocking for the full 900 s buys nothing: after 30 s the
code lands in exactly the same state it reaches after 900 s today. On ConnectBlock the cap
trades bounded cs_main hold time for less tolerance of a slow-but-alive daemon.

Change

  • CallMainChainRPC gains an optional timeout parameter. Negative (the default) preserves the
    current behavior for every existing caller: read -mainchainrpctimeout.
  • New GetValidationRPCTimeout() returns -mainchainrpctimeout capped at
    MAX_VALIDATION_RPC_TIMEOUT (new constant, 30 seconds).
  • The capped timeout is used at exactly the two validation-path call sites:
    IsConfirmedBitcoinBlock (getblockheader) and the getrawtransaction call in
    CheckPeginSubsidyAndMinimum. IsConfirmedBitcoinBlock also serves the advisory mature
    field of createrawpegin and the immature-peg-in warning of signrawtransactionwith*, which
    fail faster against a hung daemon — harmless and desirable.
  • The startup sanity check (MainchainRPCCheck in init.cpp) and all other CallMainChainRPC
    users (wallet RPCs) are unchanged and keep the full -mainchainrpctimeout.
  • The cap reduces cs_main hold time per attempt; it does not remove the coupling. The 30 s
    reverification timer and every block arrival still trigger a fresh (now bounded) attempt.

30 s is generous for the intended deployment (a co-located, trusted bitcoind answering
getblockheader/getrawtransaction) and bounds a stall to well under one Liquid block interval
per attempt instead of 15 minutes.

Help text fix (first commit)

The -mainchainrpctimeout help text claims "0 for no timeout". That is not what happens: with
the pinned libevent 2.1.12, a timeout of 0 is treated as unset and libevent substitutes its own
internal defaults (45 s connect, 50 s read/write — HTTP_CONNECT_TIMEOUT/HTTP_READ_TIMEOUT/
HTTP_WRITE_TIMEOUT in http-internal.h, applied in http.c). There is no way to disable the
timeout through this option. The help text now states the actual behavior (and, in the second
commit, documents the validation cap).

Backward compatibility

  • Operators who set -mainchainrpctimeout to a value ≤ 30 see no change in validation behavior;
    values above 30 (including the 900 s default) are capped at 30 s for the two validation-path
    call sites only.
  • -mainchainrpctimeout=0: the current head clamps 0 into [1, 30], i.e. a 1 s validation
    timeout, which is a regression for anyone who followed the old help text (today they get
    libevent's 45/50 s). A fixup making 0 and negative values yield the cap instead is pending.
  • All other mainchain RPC consumers keep the configured timeout.
  • No new options, no changes to consensus rules, mempool policy, or RPC interfaces.
  • Failure paths are unchanged; the ConnectBlock path is reached for slow daemons that
    previously would have answered within 900 s (see above).

Test

Adds mainchainrpc_tests.cpp covering GetValidationRPCTimeout: default capped, values below
the cap honored, values above the cap capped, boundary value honored, and the 0 case. Nothing
exercises the hung-daemon path end to end; a functional harness (a stub that answers the startup
getnetworkinfo/getblockhash calls, then accepts and never replies) is feasible and I'm happy to
add it if reviewers want it.

Notes for reviewers

  • ConnectBlock / elementsd stuck syncing at block 2137819 #1198: the cleanest outcome is to carry depth_failed out of
    CheckTxInputs so that a mainchain failure in ConnectBlock stalls rather than invalidates,
    which would make the cap unconditionally safe. That is a separate consensus-adjacent change;
    I can prepare it as a preceding commit or separate PR if maintainers want the two paired.
    Alternatively the cap could be applied only on the retryable paths. Guidance welcome.
  • Cap value: 30 s was chosen as generous for the intended deployment (a co-located, trusted
    bitcoind) while bounding a stall to well under one Liquid block interval; 60 s would work
    just as well if preferred.
  • Clamp vs. knob: a separate configurable validation-path timeout was considered and
    rejected to keep the option surface minimal; the patch is structured so that swapping the
    constant for an argument is a two-line change if maintainers prefer that.
  • Help text alternative: bitcoin-cli in this tree makes -rpcclienttimeout=0 mean a
    multi-year timeout ("Indefinite request timeouts are not possible in libevent-http"). Making 0
    behave as documented for the non-validation callers, instead of documenting libevent
    internals, is an equally small change if preferred.
  • Backport: elements-29.x and elements-23.x have both call sites; elements-23.3.x has no
    CheckPeginSubsidyAndMinimum, so only the IsConfirmedBitcoinBlock half applies there.

Edited 2026-10-15: corrected the failure-semantics section (the ConnectBlock path invalidates rather than retries — #1198 — and the getrawtransaction timeout propagates as CConnectionFailed, not as pegin-subsidy-mainchain-error), noted the -mainchainrpctimeout=0 → 1 s regression in the current head with a fixup pending, corrected the per-input/per-attempt cost and the backport branch names, and added the signrawtransactionwith* call site and the help-text alternative.

The help text claims a value of 0 disables the timeout. It does not:
with the pinned libevent 2.1.12, a timeout of 0 is treated as unset and
libevent substitutes its own internal defaults (45s connect, 50s
read/write). There is no way to disable the timeout via this option.
Peg-in validation (-validatepegin, default on for chains with a federated
peg) issues synchronous mainchain RPC calls while holding cs_main: during
block connection (ConnectTip via CheckPeginRipeness; ConnectBlock via
Consensus::CheckTxInputs), during mempool acceptance (PreChecks via
CheckPeginSubsidyAndMinimum), and during mempool epoch ejection
(removeForBlock). Each call uses the full -mainchainrpctimeout (default
900s) with a fresh blocking TCP connection and no caching, so a mainchain
daemon that accepts connections but stops responding can stall block
validation and mempool acceptance for up to 15 minutes per peg-in input.

IsConfirmedBitcoinBlock treats RPC failure as 'not confirmed, retry
later', and the getrawtransaction call in CheckPeginSubsidyAndMinimum
fails the transaction into the same retryable mempool-rejection path, so
blocking for the full timeout has no benefit on these paths.

Give CallMainChainRPC an optional timeout parameter (negative preserves
the existing behavior for all other callers), add
GetValidationRPCTimeout() clamping -mainchainrpctimeout into
[1, MAX_VALIDATION_RPC_TIMEOUT] (30s), and use it for the getblockheader
call in IsConfirmedBitcoinBlock and the getrawtransaction call in
CheckPeginSubsidyAndMinimum. This also bounds the advisory maturity check
in createrawpegin. The startup MainchainRPCCheck keeps the full
configured timeout. The help text now documents the cap.
@GracedEternalKingCabbageMan

Copy link
Copy Markdown
Author

Self-review addendum after a second pass; the description has been updated to match (edit note at the bottom).

  1. Regression in the current head: -mainchainrpctimeout=0 becomes a 1 s validation timeout. GetValidationRPCTimeout clamps into [1, 30], so operators who followed the old "0 for no timeout" help text (and today get libevent's 45/50 s defaults) would have getblockheader/getrawtransaction time out after 1 s during validation. Fixup incoming: 0 and negative values will yield the cap instead, with the test and header comment updated.

  2. The description overstated the safety argument. IsConfirmedBitcoinBlock is also reached from ConnectBlock via Consensus::CheckTxInputs (depth checking on, no depth_failed out-param), where any failure is bad-pegin-witness → BLOCK_CONSENSUS → BLOCK_FAILED_VALID. That is the pre-existing elementsd stuck syncing at block 2137819 #1198 (daemon goes away between CheckPeginRipeness and ConnectBlock). The cap does not create that hazard, but it does turn a daemon that answers in 30–900 s from a success into a failure on that path. A timeout can never make an invalid peg-in look valid (IsConfirmedBitcoinBlock only ever returns false on exception), so this is liveness, not safety. Two ways forward, and I'm happy with either: (a) pair this with a fix for elementsd stuck syncing at block 2137819 #1198 that carries depth_failed out of CheckTxInputs so ConnectBlock stalls rather than invalidates, making the cap unconditionally safe; or (b) merge the cap as a documented trade-off. I can prepare (a) if maintainers prefer it.

  3. Correction on the getrawtransaction path: a timeout there throws CConnectionFailed out of AcceptToMemoryPool (there is no try in CheckPeginSubsidyAndMinimum); it does not land in pegin-subsidy-mainchain-error. Same as after 900 s today, only sooner. (Side note, pre-existing: bitcoind returns error as an object, so the result["error"].isStr() branch that produces that reject reason is effectively unreachable.)

  4. Help-text commit alternative: bitcoin-cli in this tree maps -rpcclienttimeout=0 to a multi-year timeout rather than documenting libevent's internals. Doing the same for the non-validation callers of CallMainChainRPC is an equally small change if that's preferred over the wording fix.

CI hasn't run yet (workflow awaiting approval); would appreciate a maintainer approving the run once the fixup is pushed.

GetValidationRPCTimeout clamped -mainchainrpctimeout into [1, cap], so
an operator who had set 0 (documented until the previous commit as "no
timeout", and in practice libevent's 45s/50s defaults) would have had
peg-in validation calls time out after 1 second. Treat 0 and negative
values as "no explicit limit" and return the cap instead. Also use
constexpr and doc-comment style for the new constant and declaration.
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.

1 participant